Files
deepseek-harness/packages/skill/skill/src/index.ts
T

690 lines
28 KiB
TypeScript

/**
* Agent skill provider registry.
*
* This package is the interface third of the skill capability seam. Concrete
* providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
* from; this service only merges provider catalogs, resolves the winning skill
* for a name, and exposes the winning summaries and definitions to consumers.
*
* @module @deepseek-ai/dsh-skill
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type Schema from 'schemastery'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
const MAX_COLLECT_ATTEMPTS = 2
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
/**
* Return whether a string is a valid kebab-case skill name.
* @param name - candidate skill name to validate.
* @returns whether the name matches the public skill-name grammar.
*/
export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
}
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =
| { readonly kind: 'directory'; readonly path: string }
| { readonly kind: 'url'; readonly url: string }
| { readonly kind: 'opaque'; readonly description: string }
/** Invocation controls shared by skill discovery consumers. */
export interface SkillInvocationPolicy {
/** Whether model-facing catalogs and loaders include this skill. */
readonly modelInvocable: boolean
/** Whether human-facing command catalogs and loaders include this skill. */
readonly userInvocable: boolean
}
/** Invocation-neutral skill metadata returned by `ctx.skills.list()`. */
export interface SkillSummary {
/** Kebab-case identifier used to address the skill. */
readonly name: string
/** Short routing description shown by discovery consumers. */
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Resolved model and user invocation controls. */
readonly invocation: SkillInvocationPolicy
/** Discovery source that produced this winning skill. */
readonly source: SkillSource
/** Provider that owns this skill body. */
readonly provider: string
/** Provider-specific base for relative resources. */
readonly resourceBase?: SkillResourceBase
}
/** Provider catalog entry used by the registry to merge and later load skills. */
export interface SkillCandidate extends SkillSummary {
/** Lower ranks win duplicate skill names before provider registration order is considered. */
readonly rank: number
/** Opaque provider-owned handle passed back to `provider.get()`. */
readonly locator: unknown
/** Absolute file path when the provider has one. */
readonly path?: string
/** Parsed optional metadata object from provider-specific skill frontmatter. */
readonly metadata?: Readonly<Record<string, unknown>>
}
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
export interface SkillDefinition extends SkillSummary {
/** Markdown instruction body after any provider-specific metadata removal. */
readonly content: string
/** Absolute file path when the skill came from disk. */
readonly path?: string
/** Parsed optional metadata object from frontmatter. */
readonly metadata?: Readonly<Record<string, unknown>>
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
/** Invocation controls; omission permits both model and user surfaces. */
readonly invocation?: SkillInvocationPolicy
/** Provider label; omission uses the registry-owned runtime provider. */
readonly provider?: string
}
/** Caller context used for cwd-sensitive and abortable provider work. */
export interface SkillLookupOptions {
/** Workspace selector for the current lookup. */
readonly cwd?: string | undefined
/** Abort discovery or loading work for the current caller. */
readonly signal?: AbortSignal | undefined
}
/**
* Return whether a skill may be advertised to and loaded by a model.
* @param skill - skill metadata carrying resolved invocation controls.
* @returns whether the policy permits model invocation.
*/
export function isModelInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation.modelInvocable
}
/**
* Return whether a skill may be advertised to and loaded by a human-facing command.
* @param skill - skill metadata carrying resolved invocation controls.
* @returns whether the policy permits user invocation.
*/
export function isUserInvocable(skill: Pick<SkillSummary, 'invocation'>): boolean {
return skill.invocation.userInvocable
}
/** One catalog observation plus whether discovery completed within a stable catalog revision. */
export interface SkillCatalogSnapshot {
/** Sorted invocation-neutral summaries collected in this observation. */
readonly skills: SkillSummary[]
/** Whether every registered provider completed without a concurrent catalog revision. */
readonly complete: boolean
}
/** Provider candidates plus whether the current discovery is authoritative. */
export interface SkillProviderObservation {
/** Candidates available from the current provider discovery. */
readonly candidates: readonly SkillCandidate[]
/** Whether discovery completed and these candidates may be cached. */
readonly complete: boolean
}
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
readonly name: string
/**
* List available skill candidates for the current lookup context. Provider
* plugins register synchronously during `apply()`; remote initialization,
* authentication, and discovery are awaited inside this method. Implementations
* should settle promptly when `options.signal` aborts.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns provider candidates as a complete-array shorthand, or an explicit
* observation when usable candidates came from incomplete discovery.
*/
readonly list: (options: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill body, or `undefined` if it is no longer loadable.
*/
readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise<SkillDefinition | undefined>
}
/** Registration-scoped lifecycle and invalidation capability borrowed by one provider. */
export interface SkillProviderControl {
/** Aborts if registration fails or when the exact provider registration is disposed. */
readonly signal: AbortSignal
/** Invalidate completed catalogs and notify consumers only while the exact registration remains active. */
readonly invalidate: () => void
}
/** Skill registry configuration. */
export interface Config {
/** Maximum number of completed cwd/provider catalogs kept in memory. */
readonly collectCacheMaxEntries?: number
}
declare module 'cordis' {
interface Context {
skills: SkillService
}
interface Events {
/**
* A skill provider, runtime contribution, or provider-backed catalog may
* have changed. This is an unfiltered invalidation notification; consumers
* refetch the catalog for their own lookup options. Listener failures are
* contained and cannot veto the registry mutation.
* @mode emit
*/
'skills/change'(): void
}
}
interface IndexedCandidate {
candidate: SkillCandidate
provider: SkillProvider
providerOrder: number
localOrder: number
}
interface CollectResult {
entries: IndexedCandidate[]
cacheable: boolean
}
/**
* Registry of skill providers. It merges provider catalogs with stable
* first-wins duplicate handling, exposes sorted invocation-neutral summaries, and
* loads full skill bodies on demand.
*/
export class SkillService extends Service {
static Config: Schema<Config> = z.object({
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
})
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, IndexedCandidate[]>()
private providerRevision = 0
private nextProviderOrder = 0
private runtimeRevision = 0
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'skills')
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
}
/**
* Register a borrowed same-process provider synchronously during plugin apply. Duplicate and
* reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters
* the provider and invalidates catalog caches.
* @param create - synchronous factory receiving this registration's lifecycle and invalidation control.
* @returns the exact Cordis effect disposer that unregisters this provider;
* composite effects may yield it directly to preserve teardown ordering.
*/
registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void {
const lifecycle = new AbortController()
let active = false
let provider: SkillProvider
const control: SkillProviderControl = {
signal: lifecycle.signal,
invalidate: () => {
if (active) this.invalidateProvider(provider)
},
}
try {
provider = create(control)
const name = provider.name
if (name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(name)) {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = this.ctx.effect(function* () {
active = true
providers.set(name, { provider, order })
invalidateCache()
yield () => {
active = false
providers.delete(name)
lifecycle.abort(new Error(`skill provider "${name}" disposed`))
invalidateCache()
}
}, 'skills.registerProvider()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve exact disposer identity
return dispose
} catch (error) {
lifecycle.abort(error)
throw error
}
}
/**
* Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which
* outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and
* receives a no-op disposer so it cannot remove the winner.
* @param skill - the skill definition input; omitted invocation and provider fields receive defaults.
* @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
*/
register(skill: SkillRegistration): () => void {
validateRuntimeSkill(skill)
const existing = this.runtime.get(skill.name)
if (existing !== undefined) {
this.ctx.logger.warn(`runtime skill "${skill.name}" ignored because it is already registered`)
return () => {}
}
const definition: SkillDefinition = {
...skill,
invocation: skill.invocation ?? { modelInvocable: true, userInvocable: true },
provider: skill.provider ?? RUNTIME_PROVIDER,
}
const runtime = this.runtime
const updateRevision = (): void => { this.runtimeRevision += 1 }
const invalidateCache = (): void => { this.invalidateCache() }
const dispose = this.ctx.effect(function* () {
runtime.set(definition.name, definition)
updateRevision()
invalidateCache()
yield () => {
runtime.delete(definition.name)
updateRevision()
invalidateCache()
}
}, 'skills.register()')
// oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose
}
/**
* List invocation-neutral skill summaries for a workspace. Consumers apply
* model or user invocation policy at their operational boundary. Lookup
* options and provider candidates are readonly same-process values borrowed
* throughout discovery.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns all sorted winning summaries.
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.snapshot(options)).skills
}
/**
* Observe the current invocation-neutral catalog and whether discovery completed within a stable revision.
* Incomplete observations are never cached, allowing consumers to retain last-good state and
* retry on their next request boundary.
* @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery.
* @returns sorted summaries plus discovery-completeness state.
*/
async snapshot(options: SkillLookupOptions = {}): Promise<SkillCatalogSnapshot> {
const collected = await this.collect(options)
return {
skills: collected.entries
.map(entry => entry.candidate)
.map(toSummary)
.sort(compareSkillSummary),
complete: collected.cacheable,
}
}
/**
* Load and validate the winning candidate, passing its opaque discovery locator back to the
* provider. Cancellation is rechecked after selection, including cache hits, and raced against
* loading so an uncooperative provider cannot hang the caller.
* @param name - kebab-case skill name.
* @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
* @returns the full skill, including body content, or `undefined`.
*/
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
const collected = await this.collect(options)
throwIfAborted(options.signal)
const match = collected.entries.find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
const definition = await waitWithAbort(
match.provider.get(match.candidate, options),
options.signal,
)
if (definition === undefined) return undefined
validateDefinition(definition)
if (definition.name !== match.candidate.name) {
this.invalidateProvider(match.provider)
return undefined
}
return definition
}
private async collect(options: SkillLookupOptions): Promise<CollectResult> {
throwIfAborted(options.signal)
let attempt = 1
while (true) {
const providerRevision = this.providerRevision
const runtimeRevision = this.runtimeRevision
const key = collectCacheKey(options, providerRevision, runtimeRevision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return { entries: cached, cacheable: true }
const result = await this.collectFresh(options)
throwIfAborted(options.signal)
if (providerRevision !== this.providerRevision || runtimeRevision !== this.runtimeRevision) {
if (attempt < MAX_COLLECT_ATTEMPTS) {
attempt += 1
continue
}
return { entries: result.entries, cacheable: false }
}
if (result.cacheable) {
this.collectCache.set(key, result.entries)
if (this.collectCache.size > this.collectCacheMaxEntries) {
const oldest = this.collectCache.keys().next() as IteratorYieldResult<string>
this.collectCache.delete(oldest.value)
}
}
return result
}
}
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
const collected = await this.listAllCandidates(options)
collected.entries.sort(compareIndexedCandidates)
const seen = new Set<string>()
const result: IndexedCandidate[] = []
for (const entry of collected.entries) {
const skill = entry.candidate
if (seen.has(skill.name)) {
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)
continue
}
seen.add(skill.name)
result.push(entry)
}
return { entries: result, cacheable: collected.cacheable }
}
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
throwIfAborted(options.signal)
const candidates: IndexedCandidate[] = []
let cacheable = true
let runtimeOrder = 0
for (const skill of [...this.runtime.values()].sort((a, b) => compareCodePoints(a.name, b.name))) {
candidates.push({
candidate: runtimeCandidate(skill),
provider: RUNTIME_SKILL_PROVIDER,
providerOrder: -1,
localOrder: runtimeOrder,
})
runtimeOrder += 1
}
for (const { provider, order } of [...this.providers.values()]) {
let localOrder = 0
let output: unknown
try {
output = await waitWithAbort(provider.list(options), options.signal)
} catch (error) {
if (options.signal?.aborted === true) throw toError(options.signal.reason)
cacheable = false
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
}
if (output === undefined) continue
const observation = normalizeProviderObservation(output, provider.name)
if (!observation.complete) cacheable = false
for (const candidate of observation.candidates) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
}
}
return { entries: candidates, cacheable }
}
private invalidateCache(): void {
this.providerRevision += 1
this.collectCache.clear()
this.notifyChange()
}
private invalidateProvider(provider: SkillProvider): void {
/* v8 ignore else -- A definition load can outlive the exact provider registration it selected. */
if (this.providers.get(provider.name)?.provider === provider) this.invalidateCache()
}
/** Notify catalog observers without making their refresh work load-bearing. */
private notifyChange(): void {
for (const callback of this.ctx.events.dispatch('emit', ['skills/change'])) {
try {
const returned: unknown = callback()
void Promise.resolve(returned).catch((error: unknown) => {
this.ctx.logger.warn(`skills/change listener rejected: ${errorMessage(error)}`)
})
} catch (error: unknown) {
this.ctx.logger.warn(`skills/change listener threw: ${errorMessage(error)}`)
}
}
}
}
function normalizeProviderObservation(output: unknown, providerName: string): SkillProviderObservation {
if (Array.isArray(output)) {
return { candidates: output as readonly SkillCandidate[], complete: true }
}
if (output === null || typeof output !== 'object') {
throw invalidProviderObservation(providerName)
}
const observation = output as Partial<SkillProviderObservation>
if (!Array.isArray(observation.candidates) || typeof observation.complete !== 'boolean') {
throw invalidProviderObservation(providerName)
}
return observation as SkillProviderObservation
}
function invalidProviderObservation(providerName: string): TypeError {
return new TypeError(`skill provider "${providerName}" list() must return an array or { candidates, complete } observation`)
}
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
name: RUNTIME_PROVIDER,
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
list() {
return Promise.resolve([])
},
get(candidate) {
return Promise.resolve(candidate.locator as SkillDefinition)
},
}
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
return {
name: skill.name,
description: skill.description,
...skill.whenToUse !== undefined ? { whenToUse: skill.whenToUse } : {},
invocation: skill.invocation,
source: skill.source,
provider: skill.provider,
...skill.resourceBase !== undefined ? { resourceBase: skill.resourceBase } : {},
rank: RUNTIME_RANK,
locator: skill,
...skill.path !== undefined ? { path: skill.path } : {},
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
}
}
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
if (typeof candidate.name !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned a non-string skill name`)
}
if (!SKILL_NAME.test(candidate.name)) {
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
}
if (typeof candidate.description !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string description`)
}
if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
}
validateInvocation(candidate.invocation, `skill provider "${providerName}" returned skill "${candidate.name}"`)
if (candidate.whenToUse !== undefined && typeof candidate.whenToUse !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string whenToUse`)
}
if (typeof candidate.source !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string source`)
}
if (typeof candidate.rank !== 'number' || !Number.isFinite(candidate.rank)) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
}
if (typeof candidate.provider !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string provider`)
}
if (candidate.provider !== providerName) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
}
if (candidate.path !== undefined && typeof candidate.path !== 'string') {
throw new TypeError(`skill provider "${providerName}" returned skill "${candidate.name}" with a non-string path`)
}
}
function validateRuntimeSkill(skill: SkillRegistration): void {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
validateInvocation(skill.invocation, `runtime skill "${skill.name}"`)
}
/** Validate a definition loaded from a provider-controlled parser or remote source. */
function validateDefinition(skill: SkillDefinition): void {
const name = skill.name
const description = skill.description
const whenToUse = skill.whenToUse
const invocation = skill.invocation
const source = skill.source
const provider = skill.provider
const content = skill.content
const path = skill.path
if (typeof name !== 'string') throw new TypeError('loaded skill name must be a string')
if (!SKILL_NAME.test(name)) throw new Error(`loaded skill has invalid name "${name}"`)
if (typeof description !== 'string') throw new TypeError(`loaded skill "${name}" description must be a string`)
if (description.length === 0) throw new Error(`loaded skill "${name}" requires a description`)
validateInvocation(invocation, `loaded skill "${name}"`)
if (whenToUse !== undefined && typeof whenToUse !== 'string') throw new TypeError(`loaded skill "${name}" whenToUse must be a string`)
if (typeof source !== 'string') throw new TypeError(`loaded skill "${name}" source must be a string`)
if (typeof provider !== 'string') throw new TypeError(`loaded skill "${name}" provider must be a string`)
if (typeof content !== 'string') throw new TypeError(`loaded skill "${name}" content must be a string`)
if (path !== undefined && typeof path !== 'string') throw new TypeError(`loaded skill "${name}" path must be a string`)
}
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
const { name, description, whenToUse, invocation, source, provider, resourceBase } = skill
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
invocation,
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
}
}
function validateInvocation(invocation: unknown, subject: string): void {
if (invocation === undefined) return
if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) {
throw new TypeError(`${subject} with a non-object invocation policy`)
}
const policy = invocation as Record<string, unknown>
if (typeof policy.modelInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.modelInvocable`)
}
if (typeof policy.userInvocable !== 'boolean') {
throw new TypeError(`${subject} with a non-boolean invocation.userInvocable`)
}
}
function compareSkillSummary(left: SkillSummary, right: SkillSummary): number {
return compareCodePoints(left.name, right.name)
}
function compareCodePoints(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
return left.candidate.rank - right.candidate.rank
|| left.providerOrder - right.providerOrder
|| left.localOrder - right.localOrder
}
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
if (!Number.isInteger(value) || value < minimum) {
throw new Error(`skill: ${name} must be an integer greater than or equal to ${minimum}`)
}
}
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
}
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise
throwIfAborted(signal)
return new Promise<T>((resolve, reject) => {
const cleanup = (): void => {
signal.removeEventListener('abort', onAbort)
}
const onAbort = (): void => {
cleanup()
reject(toError(signal.reason))
}
signal.addEventListener('abort', onAbort, { once: true })
void promise.then(
(value) => {
cleanup()
resolve(value)
},
(error: unknown) => {
cleanup()
reject(toError(error))
},
)
})
}
/** Throw a total Error for an already-aborted lookup. */
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted === true) throw toError(signal.reason)
}
/** Normalize an arbitrary abort or provider failure without trusting coercion. */
function toError(error: unknown): Error {
try {
if (error instanceof Error) return error
} catch {
// A hostile proxy may throw during instanceof; fall through to the total renderer.
}
return new Error(errorMessage(error))
}
/** Render an arbitrary provider failure without letting coercion escape containment. */
function errorMessage(error: unknown): string {
try {
return String(error)
} catch {
return '[unrenderable thrown value]'
}
}
export default SkillService