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.
- package/README.md +146 -33
- package/lib/client.js +3477 -436
- package/lib/client.js.map +1 -1
- package/lib/index.js +2408 -366
- package/lib/types/client/SkillHubSettingsCard.d.ts +5 -0
- package/lib/types/client/api-config-scope.d.ts +6 -0
- package/lib/types/client/api.d.ts +44 -2
- package/lib/types/client/grouping.d.ts +64 -0
- package/lib/types/client/locales.d.ts +155 -18
- package/lib/types/client/market-catalog.d.ts +14 -0
- package/lib/types/client/panel/DisabledRow.d.ts +12 -0
- package/lib/types/client/panel/GroupSummary.d.ts +10 -0
- package/lib/types/client/panel/MarketView.d.ts +11 -0
- package/lib/types/client/panel/ScenesView.d.ts +11 -0
- package/lib/types/client/panel/SkillDetailView.d.ts +31 -0
- package/lib/types/client/panel/SkillHubPanel.d.ts +11 -3
- package/lib/types/client/panel/SkillRow.d.ts +11 -0
- package/lib/types/client/panel/SourceStatusBadge.d.ts +18 -0
- package/lib/types/client/panel/SourcesView.d.ts +10 -0
- package/lib/types/client/panel/TagEditorView.d.ts +29 -0
- package/lib/types/client/panel/dialogs.d.ts +73 -0
- package/lib/types/client/panel/format.d.ts +14 -0
- package/lib/types/client/panel/useSkillHub.d.ts +183 -0
- package/lib/types/client/settings-card.d.ts +21 -4
- package/lib/types/client/settings-form.d.ts +2 -0
- package/lib/types/client/settings-nav-icon.d.ts +8 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/protocol.d.ts +428 -7
- package/lib/types/provider.d.ts +6 -1
- package/lib/types/repo.d.ts +133 -0
- package/lib/types/routes.d.ts +9 -4
- package/lib/types/skillfs.d.ts +19 -14
- package/lib/types/stats.d.ts +17 -4
- package/lib/types/store.d.ts +112 -6
- package/lib/types/update.d.ts +20 -0
- package/package.json +5 -3
- package/src/client/SkillHubSettingsCard.tsx +61 -8
- package/src/client/api-config-scope.test.ts +79 -0
- package/src/client/api-config-scope.ts +28 -4
- package/src/client/api.ts +239 -9
- package/src/client/grouping.test.ts +109 -0
- package/src/client/grouping.ts +120 -0
- package/src/client/index.tsx +5 -2
- package/src/client/locales.ts +314 -40
- package/src/client/market-catalog.ts +22 -0
- package/src/client/panel/DisabledRow.tsx +32 -0
- package/src/client/panel/GroupSummary.tsx +27 -0
- package/src/client/panel/MarketView.tsx +173 -0
- package/src/client/panel/ScenesView.tsx +75 -0
- package/src/client/panel/SkillDetailView.tsx +95 -0
- package/src/client/panel/SkillHubPanel.tsx +251 -257
- package/src/client/panel/SkillRow.tsx +54 -0
- package/src/client/panel/SourceStatusBadge.tsx +61 -0
- package/src/client/panel/SourcesView.tsx +104 -0
- package/src/client/panel/TagEditorView.tsx +89 -0
- package/src/client/panel/dialogs.tsx +185 -0
- package/src/client/panel/format.ts +31 -0
- package/src/client/panel/panel.module.css +209 -45
- package/src/client/panel/useSkillHub.ts +797 -0
- package/src/client/settings-card.module.css +44 -28
- package/src/client/settings-card.tsx +64 -8
- package/src/client/settings-form.ts +12 -0
- package/src/client/settings-nav-icon.ts +42 -0
- package/src/index.ts +19 -15
- package/src/protocol.ts +460 -7
- package/src/provider.test.ts +2 -4
- package/src/provider.ts +18 -14
- package/src/repo.test.ts +212 -0
- package/src/repo.ts +528 -0
- package/src/routes.test.ts +564 -52
- package/src/routes.ts +999 -153
- package/src/skillfs.test.ts +98 -16
- package/src/skillfs.ts +68 -33
- package/src/stats.test.ts +52 -17
- package/src/stats.ts +55 -18
- package/src/store.test.ts +205 -1
- package/src/store.ts +527 -24
- package/src/update.test.ts +73 -0
- package/src/update.ts +136 -0
package/src/skillfs.test.ts
CHANGED
|
@@ -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('
|
|
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', '
|
|
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
|
-
|
|
61
|
-
|
|
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 } : {}),
|
|
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
|
-
*
|
|
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
|
|
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
|
|
247
|
+
/** Scan one writable root. */
|
|
228
248
|
export function listSkillEntries(root: WritableRoot, home = dshHome()): Promise<SkillEntry[]> {
|
|
229
|
-
return scanRoot(rootPath(root, home)
|
|
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)
|
|
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
|
|
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
|
|
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('
|
|
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,
|
|
129
|
-
await reader()
|
|
130
|
-
await
|
|
131
|
-
expect(
|
|
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
|
-
/**
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
65
|
+
return stats
|
|
53
66
|
}
|
|
54
67
|
|
|
55
|
-
/** Scan every session and total per-skill
|
|
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,
|
|
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,
|
|
67
|
-
|
|
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()]
|
|
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
|
-
/**
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
}
|