diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index 6e0be472..b55e2ed3 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -8,9 +8,11 @@ import { partitionMetadataEntries } from '../utils/skill-metadata' import { resolveSkillPageState } from '../utils/skill-page-state' import { resolveSkillRawUrl } from '../utils/skill-raw-url' import { resolveSkillTitle } from '../utils/skill-title' +import { isUniqueValueTreatment } from '../utils/skill-unique-value-experiment' import SkillCommandPanel from './_SkillCommandPanel.vue' import SkillReceiptsPanel from './_SkillReceiptsPanel.vue' import SkillThirdPartyChecks from './_SkillThirdPartyChecks.vue' +import SkillUniqueValue from './_SkillUniqueValue.vue' const props = defineProps<{ owner: string @@ -22,6 +24,8 @@ const owner = computed(() => props.owner) const repo = computed(() => props.repo) const name = computed(() => props.name) const slug = computed(() => `${owner.value}/${repo.value}/${name.value}`) +// SEO experiment C (skill-unique-value-experiment.ts). Same page for every visitor. +const uniqueValueTreatment = computed(() => isUniqueValueTreatment(slug.value)) interface RelatedSkill { name: string @@ -1345,8 +1349,26 @@ useHead(computed(() => ({
{{ commands.run }}
+ + Your Agent reads the Skill now. Nothing lands in your repository. +
++ Install command +
+{{ commands.install }}
+ + Install it to keep the Skill in every session. +
++ {{ files.total.toLocaleString() }} files +
++ {{ files.markdown.toLocaleString() }} markdown, {{ files.code.toLocaleString() }} code +
++ {{ files.codePaths.slice(0, 3).join(', ') }} + and {{ (files.codePaths.length - 3).toLocaleString() }} more + +
+ ++ {{ pushedAtAgo }} +
++ Update pace: {{ CADENCE_LABEL[maturity.cadence] }} +
+
+
+ + Read the full SKILL.md on GitHub + + + Full SKILL.md as markdown + +
+]/i.test(block))
+ return block.split('\n').length <= MAX_CODE_LINES && countHtmlWords(block) <= MAX_CODE_WORDS
+ return true
+ })
+
+ // Sections start at a heading. Text ahead of the first heading is its own section.
+ const sections: string[][] = []
+ for (const block of usable) {
+ if (isHeading(block) || sections.length === 0)
+ sections.push([block])
+ else
+ sections.at(-1)!.push(block)
+ }
+ const bodyWords = (section: string[]): number =>
+ section.filter(b => !isHeading(b)).reduce((sum, b) => sum + countHtmlWords(b), 0)
+
+ const chosen = [sections[0] ?? []]
+ let next = 1
+ while (bodyWords(chosen.flat()) < shortSectionWords && sections[next] && chosen.length < 2) {
+ chosen.push(sections[next]!)
+ next++
+ }
+ // A first section with no body pulls in following sections until one has text.
+ while (bodyWords(chosen.flat()) === 0 && sections[next]) {
+ chosen.push(sections[next]!)
+ next++
+ }
+
+ const out: string[] = []
+ let words = 0
+ let bodyBlocks = 0
+ for (const block of chosen.flat()) {
+ const w = countHtmlWords(block)
+ if (words + w <= maxWords) {
+ out.push(block)
+ words += w
+ if (!isHeading(block))
+ bodyBlocks++
+ continue
+ }
+ if (isHeading(block))
+ break
+ const trimmed = trimBlock(block, maxWords - words)
+ if (trimmed) {
+ out.push(trimmed)
+ bodyBlocks++
+ }
+ break
+ }
+ while (out.length && isHeading(out.at(-1)!))
+ out.pop()
+ return bodyBlocks > 0 ? out.join('\n') : null
+}
diff --git a/test/unit/skill-unique-value-experiment.test.ts b/test/unit/skill-unique-value-experiment.test.ts
new file mode 100644
index 00000000..7d95f934
--- /dev/null
+++ b/test/unit/skill-unique-value-experiment.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isUniqueValueTreatment,
+ UNIQUE_VALUE_EXPERIMENT,
+} from '../../layers/registry/app/utils/skill-unique-value-experiment'
+
+describe('unique value experiment gate', () => {
+ it('treats every listed treatment Skill', () => {
+ for (const slug of UNIQUE_VALUE_EXPERIMENT.treatment)
+ expect(isUniqueValueTreatment(slug)).toBe(true)
+ })
+
+ it('leaves every listed control Skill unchanged', () => {
+ for (const slug of UNIQUE_VALUE_EXPERIMENT.control)
+ expect(isUniqueValueTreatment(slug)).toBe(false)
+ })
+
+ it('leaves a Skill outside both lists unchanged', () => {
+ expect(isUniqueValueTreatment('someone/else/not-listed')).toBe(false)
+ })
+
+ it('ignores letter case, because GitHub logins ignore it', () => {
+ const [first] = UNIQUE_VALUE_EXPERIMENT.treatment
+ expect(isUniqueValueTreatment(first!.toUpperCase())).toBe(true)
+ })
+
+ it('keeps the two groups the same size and apart', () => {
+ const { treatment, control } = UNIQUE_VALUE_EXPERIMENT
+ expect(treatment).toHaveLength(10)
+ expect(control).toHaveLength(10)
+ expect(new Set([...treatment, ...control].map(s => s.toLowerCase())).size).toBe(20)
+ })
+})
diff --git a/test/unit/skill-unique-value.test.ts b/test/unit/skill-unique-value.test.ts
new file mode 100644
index 00000000..5f14eb8a
--- /dev/null
+++ b/test/unit/skill-unique-value.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest'
+import {
+ countHtmlWords,
+ excerptSkillHtml,
+ resolveSkillCommands,
+ resolveSkillFileFacts,
+} from '../../layers/registry/app/utils/skill-unique-value'
+
+const ref = { owner: 'acme', repo: 'skills', name: 'lint' }
+
+function asset(path: string, type: 'markdown' | 'code' | 'image' | 'data' | 'other') {
+ return { path, size: 10, type }
+}
+
+describe('resolveSkillFileFacts', () => {
+ it('reports a Skill that is only SKILL.md', () => {
+ expect(resolveSkillFileFacts({ assets: [], total: 0 })).toEqual({
+ _tag: 'only-skill-md',
+ })
+ })
+
+ it('counts files by type and lists code files', () => {
+ const facts = resolveSkillFileFacts({
+ assets: [
+ asset('references/a.md', 'markdown'),
+ asset('references/b.md', 'markdown'),
+ asset('scripts/run.py', 'code'),
+ asset('data.json', 'data'),
+ ],
+ total: 4,
+ })
+ expect(facts).toEqual({
+ _tag: 'has-files',
+ total: 4,
+ markdown: 2,
+ code: 1,
+ other: 0,
+ codePaths: ['scripts/run.py'],
+ complete: true,
+ })
+ })
+
+ it('marks a truncated list as incomplete', () => {
+ const facts = resolveSkillFileFacts({ assets: [asset('a.md', 'markdown')], total: 300 })
+ expect(facts._tag === 'has-files' && facts.complete).toBe(false)
+ })
+})
+
+describe('resolveSkillCommands', () => {
+ it('leads with run and offers install as the opt-in', () => {
+ expect(resolveSkillCommands(ref)).toEqual({
+ run: 'npx skilld run acme/skills/lint',
+ install: 'npx skilld install acme/skills/lint',
+ })
+ })
+})
+
+const words = (n: number, w = 'word') => Array.from({ length: n }).fill(w).join(' ')
+const sentences = (n: number) => Array.from({ length: n }, (_, i) => `Sentence number ${i} says something.`).join(' ')
+
+describe('excerptSkillHtml', () => {
+ it('keeps the first heading with its body text', () => {
+ const html = `Title
\nIntro text.
\n${words(200)}
` + const out = excerptSkillHtml(html)! + expect(out).toContain('Intro text.
') + expect(out).toContain('${words(20)}
\nSecond body.
\nNever shown.
` + const out = excerptSkillHtml(html)! + expect(out).toContain('Second body.') + expect(out).not.toContain('Never shown.') + }) + + it('skips ahead when the first section is only a heading', () => { + const html = 'The real body starts here.
\nNot shown.
' + const out = excerptSkillHtml(html)! + expect(out).toContain('The real body starts here.') + expect(out).not.toContain('Not shown.') + }) + + it('returns null when the page holds only headings', () => { + expect(excerptSkillHtml('${words(130)}
\nSecond body.
` + expect(excerptSkillHtml(html)!).not.toContain('Second body.') + }) + + it('caps a long section at a block boundary', () => { + const para = `${words(100)}
` + const html = `/g) ?? []).length).toBe(2) + }) + + it('cuts a single oversized paragraph at a sentence end', () => { + const html = `
${sentences(80)}
` + const out = excerptSkillHtml(html, { maxWords: 100 })! + expect(countHtmlWords(out)).toBeLessThanOrEqual(100) + expect(out).toMatch(/says something\.<\/p>$/) + }) + + it('cuts an oversized list at an item boundary', () => { + const items = Array.from({ length: 60 }, (_, i) => `${description}
\nActual guidance.
` + const out = excerptSkillHtml(html, { description })! + expect(out).not.toContain('before touching code') + expect(out).toContain('Actual guidance.') + }) + + it('keeps a short code fence and drops a long one', () => { + const short = 'npm run build'
+ const long = `${Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n')}`
+ const out = excerptSkillHtml(`Intro.
\n${short}\n${long}`)! + expect(out).toContain('npm run build') + expect(out).not.toContain('line 29') + }) + + it('never returns unbalanced tags', () => { + const html = '\n\nInside
\nQuote.
\n
After.
' + const out = excerptSkillHtml(html)! + expect((out.match(//g) ?? []).length).toBe((out.match(/<\/blockquote>/g) ?? []).length) + }) + + it('returns null for an empty body', () => { + expect(excerptSkillHtml(' ')).toBeNull() + expect(excerptSkillHtml(null)).toBeNull() + }) +})