Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion layers/registry/app/components/SkillDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { avatarProxyUrl, githubAvatarProxyUrl } from '#shared/image-proxy'
import { skillPageUrl as exactSkillPageUrl, skillInstallCmd, skillRunCmd, skillRunPrompt } from '#shared/skill-commands'
import { headlineTrendingAward, trendingAwardBadgeLabel, trendingAwardLabel, trendingAwardPath } from '#shared/trending-award'
import { behaviorIcon } from '../utils/skill-behaviors'
import { resolveSkillContextChecks } from '../utils/skill-context-checks'
import { formatByteSize, formatTokenCount, resolveSkillContextCost, resolveSkillFileContext } from '../utils/skill-context-cost'
import { fileIcon, highlightLangFromPath } from '../utils/skill-file-tree'
import { partitionMetadataEntries } from '../utils/skill-metadata'
Expand Down Expand Up @@ -537,6 +538,11 @@ const contextCost = computed(() => data.value
})
: null)

const contextChecks = computed(() => resolveSkillContextChecks({
raw: data.value?.raw ?? null,
frontmatter: data.value?.frontmatter ?? null,
}))

const audits = computed<SkillAudit[]>(() => liveSkill.value?.audits ?? [])
const auditOverview = computed(() => resolveSkillAuditOverview(audits.value))

Expand Down Expand Up @@ -2142,7 +2148,7 @@ useHead(computed(() => ({
aria-hidden="true"
/>
<span v-if="viewerContext._tag === 'skill'">
<strong>{{ formatTokenCount(viewerContext.cost.tokens.metadata) }}</strong> tokens always: the name and description.
<strong>{{ formatTokenCount(viewerContext.cost.tokens.metadata) }}</strong> tokens for metadata: the name and description.
<strong>{{ formatTokenCount(viewerContext.cost.tokens.instructions) }}</strong> when used: this file.
<template v-if="viewerContext.cost.resourceFileCount">
<strong>{{ formatTokenCount(viewerContext.cost.tokens.resources) }}</strong> more on demand in {{ viewerContext.cost.resourceFileCount }} {{ viewerContext.cost.resourceFileCount === 1 ? 'file' : 'files' }}.
Expand All @@ -2158,6 +2164,10 @@ useHead(computed(() => ({
Your agent does not load this file into context unless it opens it.
</span>
</p>
<SkillContextChecks
v-if="viewerContext?._tag === 'skill' && data?.raw"
:result="contextChecks"
/>
<section
v-show="contentView === 'preview'"
class="skill-mdxg p-4 sm:p-6 relative"
Expand Down
69 changes: 69 additions & 0 deletions layers/registry/app/components/_SkillContextChecks.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { resolveSkillContextChecks } from '../utils/skill-context-checks'

const { result } = defineProps<{
result: ReturnType<typeof resolveSkillContextChecks>
}>()

const hasError = computed(() => result.checks.some(check => check.tone === 'error'))
const hasDescriptionCap = computed(() => result.checks.some(check => check.code === 'codex-description-cap' || check.code === 'claude-description-cap'))
</script>

<template>
<details class="group border-b border-default font-mono text-xs">
<summary
class="flex cursor-pointer list-none items-center gap-2 px-4 py-2 focus-visible:outline-2 focus-visible:outline-primary sm:px-6 [&::-webkit-details-marker]:hidden"
:class="hasError ? 'text-error-700 dark:text-error' : result.checks.length ? 'text-warning-800 dark:text-warning' : 'text-muted'"
>
<UIcon
:name="result.checks.length ? 'i-lucide-triangle-alert' : 'i-lucide-info'"
class="size-3.5 shrink-0"
aria-hidden="true"
/>
<span v-if="hasError">SKILL.md has missing or invalid fields</span>
<span v-else-if="hasDescriptionCap">An Agent shortens this description</span>
<span v-else-if="result.checks.length && result.claudeListing._tag === 'listed'">Description uses {{ result.claudeListing.percent.toFixed(1) }}% of example budget</span>
<span v-else>How this Skill uses context</span>
<UIcon name="i-lucide-chevron-down" class="ml-auto size-3.5 shrink-0 group-open:rotate-180" aria-hidden="true" />
</summary>
<div class="space-y-3 px-4 pb-3 font-sans text-sm sm:px-6">
<p v-if="!hasError" class="text-default">
Before choosing a Skill, your Agent reads its name and description.
All available Skills share that space.
</p>
<ul v-if="result.checks.length" class="space-y-2" aria-label="Skill context checks">
<li v-for="check in result.checks" :key="check.code" :class="check.tone === 'error' ? 'text-error-700 dark:text-error' : 'text-warning-800 dark:text-warning'">
{{ check.message }}
</li>
</ul>
<div v-if="result.claudeListing._tag === 'listed'" class="space-y-2 text-muted">
<p>
In our Claude Code example, all Skill names and descriptions share 8,000 characters.
This Skill uses β‰ˆ{{ result.claudeListing.characters }} characters, or {{ result.claudeListing.percent.toFixed(1) }}%.
</p>
<p v-if="result.checks.some(check => check.code === 'listing-share')">
The 1% threshold is a size suggestion. Longer descriptions can still fit.
</p>
<p>Your model, settings, and other Skills decide how much text your Agent can read.</p>
</div>
<p v-else-if="result.claudeListing._tag === 'explicit-only'" class="text-muted">
This Skill turns off automatic use in Claude Code.
Its description stays out of the automatic Skill list by default.
</p>
<details class="text-muted">
<summary class="cursor-pointer text-xs font-mono">
Example settings and source
</summary>
<p class="mt-2">
The example uses a 200k-token context and default Claude Code settings.
The count includes the name, description, separators, and when_to_use when present.
Codex also counts local file paths.
</p>
<p class="mt-2 text-xs">
<a href="https://github.com/smol-ai/skit/blob/08ff55d9d995b402ebd29c51bdedbe5690b9b0e0/docs/skill-context-budgets.md" target="_blank" rel="noopener noreferrer" class="underline underline-offset-2">Skit's source and limits</a>:
Codex 0.160.1, Claude Code 2.1.292.
</p>
</details>
</div>
</details>
</template>
59 changes: 59 additions & 0 deletions layers/registry/app/utils/skill-context-checks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Source-only checks. No local Agent configuration or complete listing is available here. */
export interface SkillContextCheck {
code: 'frontmatter' | 'name' | 'description' | 'codex-description-cap' | 'claude-description-cap' | 'listing-share'
tone: 'warning' | 'error'
message: string
}

type ClaudeListing
= | { _tag: 'unavailable' }
| { _tag: 'explicit-only' }
| { _tag: 'listed', characters: number, percent: number }

// Versioned observations, not tokenizer counts or a visitor's configured allowance.
// https://github.com/smol-ai/skit/blob/08ff55d9d995b402ebd29c51bdedbe5690b9b0e0/docs/skill-context-budgets.md
// Codex 0.160.1: 1,024 code points; Claude Code 2.1.292: 1,536 combined code points.
const CODEX_DESCRIPTION_CAP = 1024
const CLAUDE_DESCRIPTION_CAP = 1536
const CLAUDE_EXAMPLE_ALLOWANCE = 8000 // 200,000 context tokens Γ— 4 characters Γ— 1%.

export function resolveSkillContextChecks(input: {
raw: string | null
frontmatter: Record<string, unknown> | null
}): { checks: SkillContextCheck[], claudeListing: ClaudeListing } {
const checks: SkillContextCheck[] = []
if (!input.raw)
return { checks, claudeListing: { _tag: 'unavailable' } }
const fields = input.frontmatter
if (!fields) {
checks.push({ code: 'frontmatter', tone: 'error', message: 'SKILL.md needs a valid YAML header with a name and description.' })
return { checks, claudeListing: { _tag: 'unavailable' } }
}
const name = typeof fields.name === 'string' ? fields.name.trim() : ''
const description = typeof fields.description === 'string' ? fields.description.trim() : ''
if (!name)
checks.push({ code: 'name', tone: 'error', message: 'The name in the SKILL.md header is missing or is not text.' })
if (!description)
checks.push({ code: 'description', tone: 'error', message: 'The description in the SKILL.md header is missing or is not text.' })
if (!name || !description)
return { checks, claudeListing: { _tag: 'unavailable' } }

if (Array.from(description).length > CODEX_DESCRIPTION_CAP)
checks.push({ code: 'codex-description-cap', tone: 'warning', message: 'Codex shortens descriptions longer than 1,024 characters. Put when-to-use details first.' })

const explicitOnly = fields['disable-model-invocation'] === true || fields['disable-model-invocation'] === 'true'
if (explicitOnly)
return { checks, claudeListing: { _tag: 'explicit-only' } }

const combined = [description, typeof fields.when_to_use === 'string' ? fields.when_to_use : ''].filter(Boolean).join(' ').trim()
const combinedCharacters = Array.from(combined)
if (combinedCharacters.length > CLAUDE_DESCRIPTION_CAP)
checks.push({ code: 'claude-description-cap', tone: 'warning', message: 'Claude Code shortens description plus when_to_use after 1,536 characters by default. Put when-to-use details first.' })

// Matches Skit's estimated name, separator, capped description, and newline row.
const characters = Array.from(`${name}: ${combinedCharacters.slice(0, CLAUDE_DESCRIPTION_CAP).join('')}\n`).length
const percent = characters / CLAUDE_EXAMPLE_ALLOWANCE * 100
if (characters * 100 > CLAUDE_EXAMPLE_ALLOWANCE)
checks.push({ code: 'listing-share', tone: 'warning', message: 'A shorter description leaves more room for other Skills. This entry exceeds our 1% size suggestion.' })
return { checks, claudeListing: { _tag: 'listed', characters, percent } }
}
37 changes: 37 additions & 0 deletions test/unit/skill-context-checks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { resolveSkillContextChecks } from '../../layers/registry/app/utils/skill-context-checks'

const source = (frontmatter: Record<string, unknown> | null) => ({ raw: 'source', frontmatter })

describe('skill context checks', () => {
it('counts the rendered Claude row and flags strictly above 1% of the example allowance', () => {
const atLimit = resolveSkillContextChecks(source({ name: 'x', description: 'a'.repeat(76) }))
const above = resolveSkillContextChecks(source({ name: 'x', description: 'a'.repeat(77) }))
expect(atLimit.claudeListing).toEqual({ _tag: 'listed', characters: 80, percent: 1 })
expect(atLimit.checks).toEqual([])
expect(above.checks.map(check => check.code)).toEqual(['listing-share'])
})

it('caps Unicode descriptions by characters and includes when_to_use for Claude', () => {
const result = resolveSkillContextChecks(source({ name: 'x', description: 'πŸ˜€'.repeat(1025), when_to_use: 'b'.repeat(512) }))
expect(result.checks.map(check => check.code)).toEqual(['codex-description-cap', 'claude-description-cap', 'listing-share'])
expect(result.claudeListing).toEqual({ _tag: 'listed', characters: 1540, percent: 19.25 })
})

it('keeps explicit-only Claude Skills out of the listing estimate', () => {
const result = resolveSkillContextChecks(source({ 'name': 'x', 'description': 'a'.repeat(2000), 'disable-model-invocation': true }))
expect(result.claudeListing).toEqual({ _tag: 'explicit-only' })
expect(result.checks.map(check => check.code)).toEqual(['codex-description-cap'])
})

it('reports missing or invalid source metadata without counting repository fallback text', () => {
expect(resolveSkillContextChecks(source(null)).checks.map(check => check.code)).toEqual(['frontmatter'])
const invalid = resolveSkillContextChecks(source({ name: 5, description: ['not a string'] }))
expect(invalid.checks.map(check => check.code)).toEqual(['name', 'description'])
expect(invalid.claudeListing).toEqual({ _tag: 'unavailable' })
})

it('does not report source defects while the source is unavailable', () => {
expect(resolveSkillContextChecks({ raw: null, frontmatter: null })).toEqual({ checks: [], claudeListing: { _tag: 'unavailable' } })
})
})
Loading