dsh-skill-hub 0.1.0 → 0.2.0

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.
Files changed (79) hide show
  1. package/README.md +146 -33
  2. package/lib/client.js +3477 -436
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2408 -366
  5. package/lib/types/client/SkillHubSettingsCard.d.ts +5 -0
  6. package/lib/types/client/api-config-scope.d.ts +6 -0
  7. package/lib/types/client/api.d.ts +44 -2
  8. package/lib/types/client/grouping.d.ts +64 -0
  9. package/lib/types/client/locales.d.ts +155 -18
  10. package/lib/types/client/market-catalog.d.ts +14 -0
  11. package/lib/types/client/panel/DisabledRow.d.ts +12 -0
  12. package/lib/types/client/panel/GroupSummary.d.ts +10 -0
  13. package/lib/types/client/panel/MarketView.d.ts +11 -0
  14. package/lib/types/client/panel/ScenesView.d.ts +11 -0
  15. package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
  16. package/lib/types/client/panel/SkillHubPanel.d.ts +11 -3
  17. package/lib/types/client/panel/SkillRow.d.ts +11 -0
  18. package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
  19. package/lib/types/client/panel/SourcesView.d.ts +10 -0
  20. package/lib/types/client/panel/TagEditorView.d.ts +29 -0
  21. package/lib/types/client/panel/dialogs.d.ts +73 -0
  22. package/lib/types/client/panel/format.d.ts +14 -0
  23. package/lib/types/client/panel/useSkillHub.d.ts +183 -0
  24. package/lib/types/client/settings-card.d.ts +21 -4
  25. package/lib/types/client/settings-form.d.ts +2 -0
  26. package/lib/types/client/settings-nav-icon.d.ts +8 -0
  27. package/lib/types/index.d.ts +6 -0
  28. package/lib/types/protocol.d.ts +428 -7
  29. package/lib/types/provider.d.ts +6 -1
  30. package/lib/types/repo.d.ts +133 -0
  31. package/lib/types/routes.d.ts +9 -4
  32. package/lib/types/skillfs.d.ts +19 -14
  33. package/lib/types/stats.d.ts +17 -4
  34. package/lib/types/store.d.ts +112 -6
  35. package/lib/types/update.d.ts +20 -0
  36. package/package.json +5 -3
  37. package/src/client/SkillHubSettingsCard.tsx +61 -8
  38. package/src/client/api-config-scope.test.ts +79 -0
  39. package/src/client/api-config-scope.ts +28 -4
  40. package/src/client/api.ts +239 -9
  41. package/src/client/grouping.test.ts +109 -0
  42. package/src/client/grouping.ts +120 -0
  43. package/src/client/index.tsx +5 -2
  44. package/src/client/locales.ts +314 -40
  45. package/src/client/market-catalog.ts +22 -0
  46. package/src/client/panel/DisabledRow.tsx +32 -0
  47. package/src/client/panel/GroupSummary.tsx +27 -0
  48. package/src/client/panel/MarketView.tsx +173 -0
  49. package/src/client/panel/ScenesView.tsx +75 -0
  50. package/src/client/panel/SkillDetailView.tsx +95 -0
  51. package/src/client/panel/SkillHubPanel.tsx +251 -257
  52. package/src/client/panel/SkillRow.tsx +54 -0
  53. package/src/client/panel/SourceStatusBadge.tsx +61 -0
  54. package/src/client/panel/SourcesView.tsx +104 -0
  55. package/src/client/panel/TagEditorView.tsx +89 -0
  56. package/src/client/panel/dialogs.tsx +185 -0
  57. package/src/client/panel/format.ts +31 -0
  58. package/src/client/panel/panel.module.css +209 -45
  59. package/src/client/panel/useSkillHub.ts +797 -0
  60. package/src/client/settings-card.module.css +44 -28
  61. package/src/client/settings-card.tsx +64 -8
  62. package/src/client/settings-form.ts +12 -0
  63. package/src/client/settings-nav-icon.ts +42 -0
  64. package/src/index.ts +19 -15
  65. package/src/protocol.ts +460 -7
  66. package/src/provider.test.ts +2 -4
  67. package/src/provider.ts +18 -14
  68. package/src/repo.test.ts +212 -0
  69. package/src/repo.ts +528 -0
  70. package/src/routes.test.ts +564 -52
  71. package/src/routes.ts +999 -153
  72. package/src/skillfs.test.ts +98 -16
  73. package/src/skillfs.ts +68 -33
  74. package/src/stats.test.ts +52 -17
  75. package/src/stats.ts +55 -18
  76. package/src/store.test.ts +205 -1
  77. package/src/store.ts +527 -24
  78. package/src/update.test.ts +73 -0
  79. package/src/update.ts +136 -0
@@ -2,7 +2,7 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promise
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
- import { createSkill, disableSkill, enableSkill, parseFrontmatter, rootOfPath, scanDiagnostics } from './skillfs.ts'
5
+ import { clearTrash, createSkill, disableSkill, enableSkill, parseFrontmatter, restoreSkill, rootOfPath, scanDiagnostics, trashSkill } from './skillfs.ts'
6
6
 
7
7
  describe('parseFrontmatter', () => {
8
8
  it('parses a healthy frontmatter', () => {
@@ -86,20 +86,10 @@ describe('parseFrontmatter', () => {
86
86
  expect(parseFrontmatter('---\nname: ok-name\n---')).toEqual({ error: 'frontmatter requires a description field' })
87
87
  })
88
88
 
89
- it('parses a single-string sets field into a list', () => {
90
- const parsed = parseFrontmatter('---\nname: ok-name\ndescription: x\nsets: engineering\n---')
91
- expect(parsed).toMatchObject({ value: { sets: ['engineering'] } })
92
- })
93
-
94
- it('parses an array sets field, trimming and dropping empties', () => {
95
- const parsed = parseFrontmatter('---\nname: ok-name\ndescription: x\nsets: [engineering, " 3d ", ""]\n---')
96
- expect(parsed).toMatchObject({ value: { sets: ['engineering', '3d'] } })
97
- })
98
-
99
- it('omits sets when the field is absent or empty', () => {
89
+ it('ignores sets frontmatter (sets grouping was removed)', () => {
100
90
  const expected = { name: 'ok-name', description: 'x', invocation: { modelInvocable: true, userInvocable: true }, content: '' }
101
- expect(parseFrontmatter('---\nname: ok-name\ndescription: x\n---')).toEqual({ value: expected })
102
- expect(parseFrontmatter('---\nname: ok-name\ndescription: x\nsets: []\n---')).toEqual({ value: expected })
91
+ expect(parseFrontmatter('---\nname: ok-name\ndescription: x\nsets: engineering\n---')).toEqual({ value: expected })
92
+ expect(parseFrontmatter('---\nname: ok-name\ndescription: x\nsets: [engineering, " 3d ", ""]\n---')).toEqual({ value: expected })
103
93
  })
104
94
  })
105
95
 
@@ -125,6 +115,21 @@ describe('createSkill / disableSkill / enableSkill', () => {
125
115
  expect(parseFrontmatter(text)).toMatchObject({ value: { name: 'demo-skill', description: 'Does demo things' } })
126
116
  })
127
117
 
118
+ it('scaffolds numeric-looking names as YAML strings', async () => {
119
+ const path = await createSkill('user-dsh', '1312', '1231', home)
120
+ const text = await readFile(path, 'utf8')
121
+ expect(text).toContain("name: '1312'")
122
+ expect(text).toContain("description: '1231'")
123
+ expect(parseFrontmatter(text)).toMatchObject({ value: { name: '1312', description: '1231' } })
124
+ })
125
+
126
+ it('escapes YAML-significant description text', async () => {
127
+ const path = await createSkill('user-dsh', 'review-skill', 'Reviews: code #1', home)
128
+ expect(parseFrontmatter(await readFile(path, 'utf8'))).toMatchObject({
129
+ value: { name: 'review-skill', description: 'Reviews: code #1' },
130
+ })
131
+ })
132
+
128
133
  it('refuses non-kebab-case names', async () => {
129
134
  await expect(createSkill('user-dsh', 'Not Valid', '', home)).rejects.toThrow(/kebab-case/)
130
135
  })
@@ -153,6 +158,68 @@ describe('createSkill / disableSkill / enableSkill', () => {
153
158
  })
154
159
  })
155
160
 
161
+ describe('trashSkill / restoreSkill', () => {
162
+ let dir: string
163
+ let home: string
164
+
165
+ beforeEach(async () => {
166
+ dir = await mkdtemp(join(tmpdir(), 'dsh-skill-hub-trash-'))
167
+ home = join(dir, 'home')
168
+ await mkdir(join(home, 'skills'), { recursive: true })
169
+ })
170
+
171
+ afterEach(async () => {
172
+ await rm(dir, { recursive: true, force: true })
173
+ })
174
+
175
+ it('trashes and restores a directory bundle', async () => {
176
+ const file = await createSkill('user-dsh', 'trash-dir', 'Trash me', home)
177
+ const { path, source } = await trashSkill(file)
178
+ expect(source).toBe(join(home, 'skills', 'trash-dir'))
179
+ expect(path.startsWith(join(home, 'skills', '.trash', 'trash-dir-'))).toBe(true)
180
+ await expect(access(file)).rejects.toThrow()
181
+ const restored = await restoreSkill({ name: 'trash-dir', path, movedAt: 1, sourcePath: source }, home)
182
+ expect(restored).toBe(source)
183
+ await expect(access(file)).resolves.toBeUndefined()
184
+ })
185
+
186
+ it('trashes and restores a flat skill file', async () => {
187
+ const flat = join(home, 'skills', 'flat-trash.md')
188
+ await writeFile(flat, '---\nname: flat-trash\ndescription: Flat\n---\n\nbody', 'utf8')
189
+ const { path, source } = await trashSkill(flat)
190
+ expect(source).toBe(flat)
191
+ await expect(access(flat)).rejects.toThrow()
192
+ const restored = await restoreSkill({ name: 'flat-trash', path, movedAt: 1, sourcePath: source }, home)
193
+ expect(restored).toBe(flat)
194
+ await expect(access(flat)).resolves.toBeUndefined()
195
+ })
196
+
197
+ it('restores a legacy directory entry without a source path', async () => {
198
+ const file = await createSkill('user-dsh', 'legacy-dir', 'Legacy', home)
199
+ const { path } = await trashSkill(file)
200
+ const target = await restoreSkill({ name: 'legacy-dir', path, movedAt: 1 }, home)
201
+ expect(target).toBe(join(home, 'skills', 'legacy-dir'))
202
+ await expect(access(join(target, 'SKILL.md'))).resolves.toBeUndefined()
203
+ })
204
+
205
+ it('refuses to restore an entry whose source path is outside the hub roots', async () => {
206
+ await expect(restoreSkill({ name: 'x', path: join(home, 'skills', '.trash', 'x-1'), movedAt: 1, sourcePath: '/outside/x' }, home))
207
+ .rejects.toThrow(/writable skill path/)
208
+ })
209
+
210
+ it('permanently clears a trashed skill', async () => {
211
+ const trashDir = join(home, 'skills', '.trash', 'gone-1')
212
+ await mkdir(trashDir, { recursive: true })
213
+ await writeFile(join(trashDir, 'SKILL.md'), '---\nname: gone\ndescription: x\n---', 'utf8')
214
+ await clearTrash({ name: 'gone', path: trashDir, movedAt: 1, sourcePath: join(home, 'skills', 'gone') }, home)
215
+ await expect(access(trashDir)).rejects.toThrow()
216
+ })
217
+
218
+ it('refuses to clear a path outside the hub trash roots', async () => {
219
+ await expect(clearTrash({ name: 'x', path: '/tmp/.trash/x-1', movedAt: 1 }, home)).rejects.toThrow(/not a hub trashed skill path/)
220
+ })
221
+ })
222
+
156
223
  describe('scanDiagnostics', () => {
157
224
  let dir: string
158
225
  let home: string
@@ -170,7 +237,7 @@ describe('scanDiagnostics', () => {
170
237
  })
171
238
 
172
239
  it('returns an empty list for a healthy root', async () => {
173
- await createSkill('user-dsh', 'good-skill', 'Good', home)
240
+ await createSkill('user-dsh', 'good-skill', 'Does good things well', home)
174
241
  expect(await scanDiagnostics('user-dsh', home)).toEqual([])
175
242
  })
176
243
 
@@ -192,6 +259,22 @@ describe('scanDiagnostics', () => {
192
259
  it('returns an empty list when the root does not exist', async () => {
193
260
  expect(await scanDiagnostics('user-dsh', join(dir, 'missing'))).toEqual([])
194
261
  })
262
+
263
+ it('flags a frontmatter name that diverges from the discovery path', async () => {
264
+ await mkdir(join(skills, 'folder-name'))
265
+ await writeFile(join(skills, 'folder-name', 'SKILL.md'), '---\nname: other-name\ndescription: A decent longer description\n---', 'utf8')
266
+ const entries = await scanDiagnostics('user-dsh', home)
267
+ expect(entries).toHaveLength(1)
268
+ expect(entries[0].reason).toContain('does not match the discovery path "folder-name"')
269
+ })
270
+
271
+ it('flags a too-short description', async () => {
272
+ await mkdir(join(skills, 'short-desc'))
273
+ await writeFile(join(skills, 'short-desc', 'SKILL.md'), '---\nname: short-desc\ndescription: x\n---', 'utf8')
274
+ const entries = await scanDiagnostics('user-dsh', home)
275
+ expect(entries).toHaveLength(1)
276
+ expect(entries[0].reason).toContain('description is only 1 chars')
277
+ })
195
278
  })
196
279
 
197
280
  describe('rootOfPath', () => {
@@ -205,4 +288,3 @@ describe('rootOfPath', () => {
205
288
  }
206
289
  })
207
290
  })
208
-
package/src/skillfs.ts CHANGED
@@ -12,13 +12,13 @@
12
12
  * content is the body after the frontmatter block, trimmed.
13
13
  */
14
14
 
15
- import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
15
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
16
16
  import { homedir } from 'node:os'
17
17
  import { basename, dirname, join, resolve } from 'node:path'
18
- import { load } from 'js-yaml'
18
+ import { dump, load } from 'js-yaml'
19
19
  import { isSkillName } from '@deepseek-ai/dsh-skill'
20
20
  import { dshHome } from './store.ts'
21
- import type { DiagnosticEntry, WritableRoot } from './protocol.ts'
21
+ import type { DiagnosticEntry, TrashEntry, WritableRoot } from './protocol.ts'
22
22
 
23
23
  /** Root ids this module may write to. */
24
24
  export const WRITABLE_ROOTS: readonly WritableRoot[] = ['user-dsh', 'user-agents']
@@ -55,10 +55,13 @@ export async function createSkill(root: WritableRoot, name: string, description:
55
55
  const dir = join(rootPath(root, home), name)
56
56
  const file = join(dir, 'SKILL.md')
57
57
  await mkdir(dir, { recursive: true })
58
+ const safeDescription = description.trim() === '' ? 'New dsh skill created from the skill hub.' : description.trim()
58
59
  const body = [
59
60
  '---',
60
- 'name: ' + name,
61
- 'description: ' + (description.trim() === '' ? 'New dsh skill created from the skill hub.' : description.trim()),
61
+ // dump() emits a quoted string when plain text would parse as a number,
62
+ // mapping, or other non-string YAML (the official parser requires strings).
63
+ 'name: ' + dump(name).trim(),
64
+ 'description: ' + dump(safeDescription).trim(),
62
65
  '---',
63
66
  '',
64
67
  '# ' + name,
@@ -95,6 +98,44 @@ export async function enableSkill(disabledPath: string): Promise<string> {
95
98
  return original
96
99
  }
97
100
 
101
+ /**
102
+ * Move a skill (directory bundle or flat .md file) into the root's trash.
103
+ * The skill stays recoverable: the source is renamed into
104
+ * <root>/.trash/<name>-<timestamp>. A directory bundle is addressed by its
105
+ * SKILL.md path and moved as a whole directory.
106
+ * @returns the trashed path plus the original path that was moved.
107
+ */
108
+ export async function trashSkill(sourcePath: string): Promise<{ path: string; source: string }> {
109
+ const source = basename(sourcePath) === 'SKILL.md' ? dirname(sourcePath) : sourcePath
110
+ const trashDir = join(dirname(source), '.trash')
111
+ await mkdir(trashDir, { recursive: true })
112
+ const target = join(trashDir, basename(source) + '-' + Date.now())
113
+ await rename(source, target)
114
+ return { path: target, source }
115
+ }
116
+
117
+ /** Restore a trashed skill (directory or flat file) to its original location. */
118
+ export async function restoreSkill(entry: TrashEntry, home = dshHome()): Promise<string> {
119
+ if (basename(dirname(entry.path)) !== '.trash') {
120
+ throw new TypeError('not a trashed skill path: ' + entry.path)
121
+ }
122
+ const target = entry.sourcePath ?? join(dirname(dirname(entry.path)), entry.name)
123
+ if (entry.sourcePath !== undefined && rootOfPath(entry.sourcePath, home) === undefined) {
124
+ throw new TypeError('not a hub writable skill path: ' + entry.sourcePath)
125
+ }
126
+ await rename(entry.path, target)
127
+ return target
128
+ }
129
+
130
+ /** Permanently delete one trashed skill (directory or flat file). */
131
+ export async function clearTrash(entry: TrashEntry, home = dshHome()): Promise<string> {
132
+ if (basename(dirname(entry.path)) !== '.trash' || rootOfPath(dirname(dirname(entry.path)), home) === undefined) {
133
+ throw new TypeError('not a hub trashed skill path: ' + entry.path)
134
+ }
135
+ await rm(entry.path, { recursive: true, force: true })
136
+ return entry.path
137
+ }
138
+
98
139
  /** Classify a skill file path: 'directory' (SKILL.md), 'flat' (<name>.md), or undefined. */
99
140
  function skillFileKind(path: string): 'directory' | 'flat' | undefined {
100
141
  if (basename(path) === 'SKILL.md') return 'directory'
@@ -107,29 +148,11 @@ export interface FrontmatterValue {
107
148
  name: string
108
149
  description: string
109
150
  whenToUse?: string
110
- /** Optional group names the skill declares (frontmatter `sets`). */
111
- sets?: string[]
112
151
  invocation: { modelInvocable: boolean; userInvocable: boolean }
113
152
  /** Instruction body after the frontmatter block, trimmed. */
114
153
  content: string
115
154
  }
116
155
 
117
- /**
118
- * Normalize an optional frontmatter `sets` value into a clean string list.
119
- * Accepts a single string or a string array; trims, drops empties, and
120
- * ignores non-string entries. Returns undefined when absent or empty, so a
121
- * skill without sets stays uncategorized.
122
- */
123
- export function normalizeSets(raw: unknown): string[] | undefined {
124
- const items = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : undefined
125
- if (items === undefined) return undefined
126
- const sets = items
127
- .filter((item): item is string => typeof item === 'string')
128
- .map((item) => item.trim())
129
- .filter((item) => item !== '')
130
- return sets.length === 0 ? undefined : sets
131
- }
132
-
133
156
  /** Parse a SKILL.md the way the official filesystem provider does. */
134
157
  export function parseFrontmatter(text: string): { value: FrontmatterValue } | { error: string } {
135
158
  const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n([\s\S]*))?$/.exec(text)
@@ -150,7 +173,6 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
150
173
  const description = typeof record.description === 'string' ? record.description.trim() : ''
151
174
  if (description === '') return { error: 'frontmatter requires a description field' }
152
175
  const whenToUse = typeof record.whenToUse === 'string' && record.whenToUse.trim() !== '' ? record.whenToUse.trim() : undefined
153
- const sets = normalizeSets(record.sets)
154
176
  if (Object.hasOwn(record, 'disableModelInvocation')) return { error: 'frontmatter field "disableModelInvocation" is unsupported; use "disable-model-invocation"' }
155
177
  if (Object.hasOwn(record, 'modelInvocable')) return { error: 'frontmatter field "modelInvocable" is unsupported; use "disable-model-invocation"' }
156
178
  if (Object.hasOwn(record, 'userInvocable')) return { error: 'frontmatter field "userInvocable" is unsupported; use "user-invocable"' }
@@ -162,7 +184,7 @@ export function parseFrontmatter(text: string): { value: FrontmatterValue } | {
162
184
  } catch (error) {
163
185
  return { error: error instanceof Error ? error.message : String(error) }
164
186
  }
165
- return { value: { name, description, ...(whenToUse !== undefined ? { whenToUse } : {}), ...(sets !== undefined ? { sets } : {}), invocation, content: (match[2] ?? '').trim() } }
187
+ return { value: { name, description, ...(whenToUse !== undefined ? { whenToUse } : {}), invocation, content: (match[2] ?? '').trim() } }
166
188
  }
167
189
 
168
190
  /** Official boolean grammar: true/false, 1/0, and the common string spellings. */
@@ -193,10 +215,9 @@ export interface SkillEntry {
193
215
  /**
194
216
  * Scan one skills root for discovery files: directory bundles (SKILL.md)
195
217
  * and flat <name>.md files. Hub-disabled files (.disabled) are excluded;
196
- * the .system subdirectory is excluded when skipSystem is set (mirrors the
197
- * official provider's user-dsh behavior).
218
+ * dot-prefixed entries (including .trash and .system) are always skipped.
198
219
  */
199
- export async function scanRoot(base: string, skipSystem: boolean): Promise<SkillEntry[]> {
220
+ export async function scanRoot(base: string): Promise<SkillEntry[]> {
200
221
  const entries: SkillEntry[] = []
201
222
  let names: string[]
202
223
  try {
@@ -207,7 +228,6 @@ export async function scanRoot(base: string, skipSystem: boolean): Promise<Skill
207
228
  }
208
229
  for (const name of names) {
209
230
  if (name.startsWith('.')) continue
210
- if (skipSystem && name === '.system') continue
211
231
  const absolute = join(base, name)
212
232
  let stats
213
233
  try {
@@ -224,9 +244,9 @@ export async function scanRoot(base: string, skipSystem: boolean): Promise<Skill
224
244
  return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
225
245
  }
226
246
 
227
- /** Scan one writable root (user-dsh skips .system, mirroring the official provider). */
247
+ /** Scan one writable root. */
228
248
  export function listSkillEntries(root: WritableRoot, home = dshHome()): Promise<SkillEntry[]> {
229
- return scanRoot(rootPath(root, home), root === 'user-dsh')
249
+ return scanRoot(rootPath(root, home))
230
250
  }
231
251
 
232
252
  /**
@@ -264,8 +284,23 @@ export async function scanDiagnostics(root: WritableRoot, home = dshHome()): Pro
264
284
  continue
265
285
  }
266
286
  const parsed = parseFrontmatter(text)
267
- if ('error' in parsed) diagnostics.push({ path: entry.path, root, reason: parsed.error })
287
+ if ('error' in parsed) {
288
+ diagnostics.push({ path: entry.path, root, reason: parsed.error })
289
+ continue
290
+ }
291
+ const { value } = parsed
292
+ // The provider registers a skill by its discovery path (directory name or
293
+ // flat file name), so a frontmatter name that diverges from the path makes
294
+ // the skill show up under a different identity than its metadata claims.
295
+ const pathName = entry.kind === 'directory' ? basename(entry.directory) : basename(entry.path, '.md')
296
+ if (value.name !== pathName) {
297
+ diagnostics.push({ path: entry.path, root, reason: `frontmatter name "${value.name}" does not match the discovery path "${pathName}" (the provider registers by path)` })
298
+ }
299
+ // Agents decide to auto-activate a skill from its one-line description, so
300
+ // a too-short description leaves the skill hard to discover automatically.
301
+ if (value.description.length < 10) {
302
+ diagnostics.push({ path: entry.path, root, reason: `description is only ${value.description.length} chars; write a one-line description so agents can auto-activate this skill` })
303
+ }
268
304
  }
269
305
  return diagnostics
270
306
  }
271
-
package/src/stats.test.ts CHANGED
@@ -3,11 +3,11 @@ import { countSkillInvocations, createSkillStatsReader, readSkillStats, type Ses
3
3
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
4
4
 
5
5
  /** Minimal user/message event carrying a skill-invocation source. */
6
- function invocationEvent(name: string, seq = 1): SessionEvent {
6
+ function invocationEvent(name: string, seq = 1, time = 0): SessionEvent {
7
7
  return {
8
8
  type: 'user/message',
9
9
  seq,
10
- time: 0,
10
+ time,
11
11
  data: {
12
12
  id: 'm' + seq as never,
13
13
  role: 'user',
@@ -28,11 +28,11 @@ function plainUserEvent(seq = 1): SessionEvent {
28
28
  }
29
29
 
30
30
  /** Minimal `skill` tool call (model-invoked skill). */
31
- function skillToolCall(name: string, seq = 1): SessionEvent {
31
+ function skillToolCall(name: string, seq = 1, time = 0): SessionEvent {
32
32
  return {
33
33
  type: 'tool/call',
34
34
  seq,
35
- time: 0,
35
+ time,
36
36
  data: { turn: 1, step: 1, callId: 'c' + seq as never, name: 'skill', arguments: JSON.stringify({ name }) },
37
37
  } as unknown as SessionEvent
38
38
  }
@@ -46,8 +46,8 @@ describe('countSkillInvocations', () => {
46
46
  plainUserEvent(4),
47
47
  ]
48
48
  const counts = countSkillInvocations(events)
49
- expect(counts.get('code-review')).toBe(2)
50
- expect(counts.get('tdd')).toBe(1)
49
+ expect(counts.get('code-review')?.count).toBe(2)
50
+ expect(counts.get('tdd')?.count).toBe(1)
51
51
  expect(counts.size).toBe(2)
52
52
  })
53
53
 
@@ -59,8 +59,8 @@ describe('countSkillInvocations', () => {
59
59
  { type: 'tool/call', seq: 4, time: 0, data: { turn: 1, step: 1, callId: 'c4', name: 'bash', arguments: '{}' } } as unknown as SessionEvent,
60
60
  ]
61
61
  const counts = countSkillInvocations(events)
62
- expect(counts.get('godot-master')).toBe(2)
63
- expect(counts.get('tdd')).toBe(1)
62
+ expect(counts.get('godot-master')?.count).toBe(2)
63
+ expect(counts.get('tdd')?.count).toBe(1)
64
64
  expect(counts.size).toBe(2)
65
65
  })
66
66
 
@@ -71,7 +71,7 @@ describe('countSkillInvocations', () => {
71
71
  { type: 'tool/call', seq: 3, time: 0, data: { turn: 1, step: 1, callId: 'c3', name: 'skill', arguments: 'not json' } } as unknown as SessionEvent,
72
72
  ]
73
73
  const counts = countSkillInvocations(events)
74
- expect(counts.get('tdd')).toBe(2)
74
+ expect(counts.get('tdd')?.count).toBe(2)
75
75
  expect(counts.size).toBe(1)
76
76
  })
77
77
 
@@ -79,6 +79,17 @@ describe('countSkillInvocations', () => {
79
79
  expect(countSkillInvocations([]).size).toBe(0)
80
80
  expect(countSkillInvocations([plainUserEvent(1)]).size).toBe(0)
81
81
  })
82
+
83
+ it('records the latest lastUsed time across events', () => {
84
+ const events = [
85
+ invocationEvent('a', 1, 1000),
86
+ invocationEvent('a', 2, 3000),
87
+ invocationEvent('a', 3, 2000),
88
+ skillToolCall('a', 4, 2500),
89
+ ]
90
+ const stats = countSkillInvocations(events)
91
+ expect(stats.get('a')).toEqual({ count: 4, lastUsed: 3000 })
92
+ })
82
93
  })
83
94
 
84
95
  describe('readSkillStats', () => {
@@ -95,8 +106,8 @@ describe('readSkillStats', () => {
95
106
  }),
96
107
  }
97
108
  expect(await readSkillStats(query)).toEqual([
98
- { name: 'code-review', count: 1 },
99
- { name: 'tdd', count: 2 },
109
+ { name: 'code-review', count: 1, lastUsed: 0 },
110
+ { name: 'tdd', count: 2, lastUsed: 0 },
100
111
  ])
101
112
  })
102
113
 
@@ -111,12 +122,30 @@ describe('readSkillStats', () => {
111
122
  return { events: [invocationEvent('tdd', 1)] }
112
123
  },
113
124
  }
114
- expect(await readSkillStats(query)).toEqual([{ name: 'tdd', count: 1 }])
125
+ expect(await readSkillStats(query)).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
115
126
  })
116
127
  })
117
128
 
118
129
  describe('createSkillStatsReader', () => {
119
- it('caches results within the TTL window', async () => {
130
+ it('returns empty on first call, then serves the background scan from cache', async () => {
131
+ let resolveScan: (() => void) | undefined
132
+ const query: SessionQueryLike = {
133
+ listSessions: async () => [{ header: { id: 'a' as never } }],
134
+ readSession: () => new Promise((resolve) => {
135
+ resolveScan = () => resolve({ events: [invocationEvent('tdd', 1)] } as never)
136
+ }),
137
+ }
138
+ const reader = createSkillStatsReader(query, 60_000)
139
+ // 首次调用不等待全量扫描:立即返回空。
140
+ expect(await reader()).toEqual([])
141
+ // 后台扫描完成后,后续调用命中缓存。
142
+ resolveScan!()
143
+ await new Promise((resolve) => setTimeout(resolve, 20))
144
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
145
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
146
+ })
147
+
148
+ it('serves stale totals after expiry and rescans only once', async () => {
120
149
  let reads = 0
121
150
  const query: SessionQueryLike = {
122
151
  listSessions: async () => [{ header: { id: 'a' as never } }],
@@ -125,9 +154,15 @@ describe('createSkillStatsReader', () => {
125
154
  return { events: [invocationEvent('tdd', 1)] }
126
155
  },
127
156
  }
128
- const reader = createSkillStatsReader(query, 60_000)
129
- await reader()
130
- await reader()
131
- expect(reads).toBe(1)
157
+ const reader = createSkillStatsReader(query, 200)
158
+ expect(await reader()).toEqual([])
159
+ await new Promise((resolve) => setTimeout(resolve, 250))
160
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
161
+ // TTL 过期时立即返回 stale 值并触发一次后台刷新;后续调用命中缓存。
162
+ await new Promise((resolve) => setTimeout(resolve, 100))
163
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
164
+ expect(await reader()).toEqual([{ name: 'tdd', count: 1, lastUsed: 0 }])
165
+ await new Promise((resolve) => setTimeout(resolve, 50))
166
+ expect(reads).toBe(2)
132
167
  })
133
168
  })
package/src/stats.ts CHANGED
@@ -25,16 +25,29 @@ export interface SessionQueryLike {
25
25
  readSession(id: SessionId): Promise<{ events: SessionEvent[] }>
26
26
  }
27
27
 
28
- /** Count skill invocations in one session's event log, keyed by skill name. */
29
- export function countSkillInvocations(events: readonly SessionEvent[]): Map<string, number> {
30
- const counts = new Map<string, number>()
31
- const bump = (name: string): void => {
32
- if (name !== '') counts.set(name, (counts.get(name) ?? 0) + 1)
28
+ /** Per-skill invocation stats in one session's event log. */
29
+ export interface InvocationStat {
30
+ count: number
31
+ /** Unix epoch ms of the most recent invocation in this batch. */
32
+ lastUsed: number
33
+ }
34
+
35
+ /** Collect per-skill invocation counts and last-used times from one session. */
36
+ export function countSkillInvocations(events: readonly SessionEvent[]): Map<string, InvocationStat> {
37
+ const stats = new Map<string, InvocationStat>()
38
+ const bump = (name: string, time: number): void => {
39
+ if (name === '') return
40
+ const current = stats.get(name)
41
+ if (current === undefined) stats.set(name, { count: 1, lastUsed: time })
42
+ else {
43
+ current.count += 1
44
+ if (time > current.lastUsed) current.lastUsed = time
45
+ }
33
46
  }
34
47
  for (const event of events) {
35
48
  if (event.type === 'user/message') {
36
49
  const source = event.data.source
37
- if (source.kind === 'skill-invocation') bump(source.name)
50
+ if (source.kind === 'skill-invocation') bump(source.name, event.time)
38
51
  } else if (event.type === 'tool/call') {
39
52
  const call = event.data
40
53
  if (call.name === 'skill') {
@@ -43,18 +56,18 @@ export function countSkillInvocations(events: readonly SessionEvent[]): Map<stri
43
56
  const parsed: unknown = JSON.parse(call.arguments)
44
57
  if (typeof parsed === 'object' && parsed !== null) {
45
58
  const name = (parsed as { name?: unknown }).name
46
- if (typeof name === 'string') bump(name)
59
+ if (typeof name === 'string') bump(name, event.time)
47
60
  }
48
61
  } catch { /* unparseable arguments skip */ }
49
62
  }
50
63
  }
51
64
  }
52
- return counts
65
+ return stats
53
66
  }
54
67
 
55
- /** Scan every session and total per-skill invocation counts. */
68
+ /** Scan every session and total per-skill counts, keeping the latest lastUsed. */
56
69
  export async function readSkillStats(query: SessionQueryLike): Promise<SkillStat[]> {
57
- const totals = new Map<string, number>()
70
+ const totals = new Map<string, InvocationStat>()
58
71
  const sessions = await query.listSessions()
59
72
  for (const record of sessions) {
60
73
  let snapshot: { events: SessionEvent[] }
@@ -63,25 +76,49 @@ export async function readSkillStats(query: SessionQueryLike): Promise<SkillStat
63
76
  } catch {
64
77
  continue // unreadable sessions are skipped, never fatal
65
78
  }
66
- for (const [name, count] of countSkillInvocations(snapshot.events)) {
67
- totals.set(name, (totals.get(name) ?? 0) + count)
79
+ for (const [name, stat] of countSkillInvocations(snapshot.events)) {
80
+ const total = totals.get(name)
81
+ if (total === undefined) totals.set(name, { ...stat })
82
+ else {
83
+ total.count += stat.count
84
+ if (stat.lastUsed > total.lastUsed) total.lastUsed = stat.lastUsed
85
+ }
68
86
  }
69
87
  }
70
- return [...totals.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name))
88
+ return [...totals.entries()]
89
+ .map(([name, stat]) => ({ name, count: stat.count, lastUsed: stat.lastUsed }))
90
+ .sort((a, b) => a.name.localeCompare(b.name))
71
91
  }
72
92
 
73
93
  /** A memoized stats reader (the panel polls, but logs change slowly). */
74
94
  export type SkillStatsReader = () => Promise<SkillStat[]>
75
95
 
76
- /** Wrap a query in a TTL cache so a 5s panel poll does not rescan every log. */
77
- export function createSkillStatsReader(query: SessionQueryLike, ttlMs = 30_000): SkillStatsReader {
96
+ /**
97
+ * Wrap a query in a stale-while-revalidate cache: responses never wait for a
98
+ * full session-log scan. While the TTL is fresh the cached totals are
99
+ * returned; after expiry the stale totals are returned immediately and a
100
+ * single background rescan refreshes them — the panel's next poll picks the
101
+ * fresh numbers. A full scan decompresses every session log and can take
102
+ * seconds, so it must never sit on the request path.
103
+ */
104
+ export function createSkillStatsReader(query: SessionQueryLike, ttlMs = 300_000): SkillStatsReader {
78
105
  let cached: SkillStat[] | undefined
79
106
  let cachedAt = 0
107
+ let refreshing: Promise<void> | null = null
80
108
  return async () => {
81
109
  const now = Date.now()
82
110
  if (cached !== undefined && now - cachedAt < ttlMs) return cached
83
- cached = await readSkillStats(query)
84
- cachedAt = Date.now()
85
- return cached
111
+ // Expired (or first call): hand back the stale totals (empty on first
112
+ // call) and kick off one background rescan.
113
+ if (refreshing === null) {
114
+ refreshing = readSkillStats(query)
115
+ .then((stats) => {
116
+ cached = stats
117
+ cachedAt = Date.now()
118
+ })
119
+ .catch(() => { /* keep the previous totals on scan failure */ })
120
+ .finally(() => { refreshing = null })
121
+ }
122
+ return cached ?? []
86
123
  }
87
124
  }