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
18 changes: 18 additions & 0 deletions COPY.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,24 @@ Every ranked section says what orders it. ADR-0004 and ADR-0010 set the rules.
The 20 in the trending header is `DEMOTED_STARRED_REPOSITORIES` in `shared/trending-range.ts`. The page and the ranking read that one constant.
Never call a ranked section top, popular, hot, best, or a leaderboard.

### Task search

The search panel offers task search for a sentence, in a row at its foot. GLOSSARY.md defines the term.
Name the model's part plainly, as a language model. Never call the action AI search or smart search.
The strings live in `taskRowCopy` in `app/components/SkillSearchPanel.vue`.

Each status shows a title, then a detail line:

- Offer: `Find skills for this task`. `A language model runs a few searches and keeps the skills that fit. Takes about 5 seconds.`
- Running: `Finding skills for this task…`. `This takes about 5 seconds.`
- Nothing fits: `No skill fits this task`. `The search results are the closest matches.`
- Visitor limit: `Too many task searches in a row`. `Try again in a minute.`
- Daily limit: `Task search reached today's limit`. `Try again tomorrow.`
- Off: `Task search is off right now`. `The search results still work.`
- Failed: `Couldn't finish the task search`. `Select to try again.`

Found Skills replace the search results under the heading `Skills for this task`, with the line `A language model picked these from a few searches of the registry.`

## Discord digest

The weekly card uses `Trending skills this week` as its title and links to the trending page.
Expand Down
9 changes: 9 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Shared terms match the skilld CLI glossary (`skilld-dev/skilld/GLOSSARY.md`). Th
| skilld token | `cli_tokens`, `layers/identity` | Author 1β€”N token | "skilld token" |
| provenance | Skill detail, cards | Skill 1β€”1 author and source link | "written by", "source" |
| behavior | `skilld-protocol/behaviors`, Skill page | Skill 1β€”N behavior | "Skill behaviors" |
| task search | search box, `layers/registry` | sentence 1β€”N Skill | "Find skills for this task" |

Collisions

Expand Down Expand Up @@ -272,6 +273,14 @@ Existing route paths, table names, and protocol fields keep their identifiers.

**Never:** newsletter, roundup, trending digest.

### task search

**Is:** the search box action for a sentence. A language model runs a few registry searches for the task and keeps the Skills that fit. It only filters and orders search results; it never adds a Skill. It runs only when a visitor selects "Find skills for this task".

**Use for:** that action, its status messages in the search panel, and the privacy page. The route is `POST /api/skills/task-search`.

**Never:** AI search, smart search, deep search, agent search, ask skilld. Those sell the model as the product, and VISION.md says skilld is not AI-powered.

## Naming new things

- Name a feature descriptively, not cleverly. "Stack selector", never "StackMatch".
Expand Down
86 changes: 84 additions & 2 deletions app/components/SkillSearchPanel.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { SearchRepository, SearchRow, SearchSkill } from '../composables/useSkillSearch'
import type { SearchRepository, SearchRow, SearchSkill, TaskSearchStatus } from '../composables/useSkillSearch'
import { githubAvatarProxyUrl } from '#shared/image-proxy'
import { searchCellId } from '../composables/useSkillSearch'
import { SEARCH_GRID_ID } from '../composables/useSkillSearchBox'
Expand All @@ -15,6 +15,7 @@ const {
recentSearches,
retry,
submitRepository,
taskSearch,
} = useSkillSearch()

/**
Expand Down Expand Up @@ -48,9 +49,47 @@ function rowKey(row: SearchRow): string {
return `skill:${row.skill.owner}/${row.skill.repo}/${row.skill.name}`
if (row._tag === 'repository' || row._tag === 'index')
return `${row._tag}:${row.repository.owner}/${row.repository.repo}`
return 'all'
return row._tag
}

interface TaskRowCopy {
title: string
detail: string
icon: string
/** Only an idle or failed task search starts when its row is selected. */
actionable: boolean
}

/** What the task search row says for each status. COPY.md holds these strings. */
function taskRowCopy(status: Exclude<TaskSearchStatus, { _tag: 'found' }>): TaskRowCopy {
switch (status._tag) {
case 'idle':
return { title: 'Find skills for this task', detail: 'A language model runs a few searches and keeps the skills that fit. Takes about 5 seconds.', icon: 'i-lucide-search-check', actionable: true }
case 'running':
return { title: 'Finding skills for this task…', detail: 'This takes about 5 seconds.', icon: 'i-lucide-loader-circle', actionable: false }
case 'none':
return { title: 'No skill fits this task', detail: 'The search results are the closest matches.', icon: 'i-lucide-circle-slash', actionable: false }
case 'limited':
return status.scope === 'visitor'
? { title: 'Too many task searches in a row', detail: 'Try again in a minute.', icon: 'i-lucide-clock', actionable: false }
: { title: 'Task search reached today\'s limit', detail: 'Try again tomorrow.', icon: 'i-lucide-clock', actionable: false }
case 'off':
return { title: 'Task search is off right now', detail: 'The search results still work.', icon: 'i-lucide-circle-slash', actionable: false }
case 'failed':
return { title: 'Couldn\'t finish the task search', detail: 'Select to try again.', icon: 'i-lucide-rotate-ccw', actionable: true }
}
}

/** Task search takes seconds, so its progress and outcome are announced. */
const taskAnnouncement = computed(() => {
const status = taskSearch.value
if (status._tag === 'idle')
return ''
if (status._tag === 'found')
return `Found ${status.skills.length} ${status.skills.length === 1 ? 'skill' : 'skills'} for this task.`
return taskRowCopy(status).title
})

function isActive(index: number, column: 0 | 1): boolean {
return index === activeIndex.value && activeColumn.value === column
}
Expand Down Expand Up @@ -212,6 +251,19 @@ function retryRepositoryIndex(): void {
</p>
</div>

<!-- Task search found Skills: they replace the search results until the query changes. -->
<div
v-if="state._tag === 'ready' && taskSearch._tag === 'found'"
class="border-b border-default px-3 pb-2 pt-3"
>
<p class="section-label">
Skills for this task
</p>
<p class="mt-1 text-xs text-muted">
A language model picked these from a few searches of the registry.
</p>
</div>

<!--
A grid, not a listbox: a Skill row holds a second control, its run
chip, and an option may not contain one. The combobox points at one
Expand All @@ -232,11 +284,19 @@ function retryRepositoryIndex(): void {
:class="{ 'search-row--active': index === activeIndex }"
@mousemove="hover(index)"
>
<!--
The box closes the panel when focus leaves it. Task search answers
inside the panel, so a press on its row keeps focus in the box.
-->
<div
:id="searchCellId(index, 0)"
role="gridcell"
:aria-selected="isActive(index, 0)"
:aria-disabled="row._tag === 'task' && !taskRowCopy(row.status).actionable ? true : undefined"
:aria-busy="row._tag === 'task' && row.status._tag === 'running' ? true : undefined"
class="search-row__open"
:class="{ 'search-row__open--static': row._tag === 'task' && !taskRowCopy(row.status).actionable }"
@mousedown="(event) => { if (row._tag === 'task') event.preventDefault() }"
@click="emit('select', row)"
>
<template v-if="row._tag === 'skill'">
Expand Down Expand Up @@ -318,6 +378,19 @@ function retryRepositoryIndex(): void {
<UIcon name="i-lucide-arrow-right" class="size-3.5 shrink-0 text-muted" aria-hidden="true" />
</template>

<template v-else-if="row._tag === 'task'">
<UIcon
:name="taskRowCopy(row.status).icon"
class="size-3.5 shrink-0 text-muted"
:class="{ 'motion-safe:animate-spin': row.status._tag === 'running' }"
aria-hidden="true"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-mono text-sm text-highlighted">{{ taskRowCopy(row.status).title }}</span>
<span class="mt-0.5 block text-xs text-muted">{{ taskRowCopy(row.status).detail }}</span>
</span>
</template>

<template v-else>
<UIcon name="i-lucide-search" class="size-3.5 shrink-0 text-muted" aria-hidden="true" />
<span class="min-w-0 flex-1 truncate font-mono text-sm">
Expand Down Expand Up @@ -349,6 +422,10 @@ function retryRepositoryIndex(): void {
</div>
</div>

<p class="sr-only" role="status" aria-live="polite">
{{ taskAnnouncement }}
</p>

<!-- Keyboard affordances, stated rather than assumed. Touch has no keys. -->
<div class="search-panel__keys flex items-center gap-4 whitespace-nowrap border-t border-default px-3 py-2">
<span class="data-label flex items-center gap-1.5">
Expand Down Expand Up @@ -396,6 +473,11 @@ function retryRepositoryIndex(): void {
cursor: pointer;
}

/* A task search row that is only reporting a status has nothing to select. */
.search-row__open--static {
cursor: default;
}

.search-row__open[aria-selected='true'] {
box-shadow: inset 2px 0 0 var(--ui-border-inverted);
}
Expand Down
70 changes: 69 additions & 1 deletion app/composables/useSkillSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SkillBoxSearchAnswer } from '#layers/registry/server/presenters/skill-box-search'
import type { TaskSearchAnswer } from '#layers/registry/server/presenters/task-search'
import type { GitHubRepository } from '#shared/github-repository'
import type {
IndexedRepositorySkill,
Expand Down Expand Up @@ -52,19 +53,34 @@ export interface SearchSkill {
/** A Repository in the registry. Its row opens the Repository page. */
export type SearchRepository = TypeaheadRepository

/**
* Task search for the sentence in the box. It runs only when the visitor
* selects its row, never per keystroke. Every status but `found` leaves the
* search results on screen.
*/
export type TaskSearchStatus
= | { _tag: 'idle' }
| { _tag: 'running' }
| { _tag: 'found', skills: SearchSkill[] }
| { _tag: 'none' }
| { _tag: 'limited', scope: 'visitor' | 'daily' }
| { _tag: 'off' }
| { _tag: 'failed' }

/**
* A row the user can move focus onto. Keeping the trailing "see everything"
* action in the same list as the results means one arrow-key model covers the
* whole panel instead of two.
*
* `repository` is a Repository the registry holds. `index` is the action for
* one it does not hold yet.
* one it does not hold yet. `task` runs task search, and then shows how it went.
*/
export type SearchRow
= | { _tag: 'skill', skill: SearchSkill, provisional: boolean }
| { _tag: 'repository', repository: SearchRepository }
| { _tag: 'index', repository: GitHubRepository }
| { _tag: 'all', query: string }
| { _tag: 'task', status: Exclude<TaskSearchStatus, { _tag: 'found' }> }

/**
* Which retrieval lanes answered. `semantic` means the lexical lane found
Expand Down Expand Up @@ -273,6 +289,43 @@ function useSkillSearchInternal() {
flush: 'sync',
})

const taskSearch = shallowRef<TaskSearchStatus>({ _tag: 'idle' })
let taskInFlight: AbortController | null = null

/**
* Run task search for the sentence in the box. It takes about 5 seconds,
* so it starts only from its row. Any failure keeps the search results.
*/
async function findForTask(): Promise<void> {
const status = taskSearch.value._tag
if (classified.value._tag !== 'intent' || (status !== 'idle' && status !== 'failed'))
return
const controller = new AbortController()
taskInFlight = controller
taskSearch.value = { _tag: 'running' }
try {
const answer = await $fetch<TaskSearchAnswer>('/api/skills/task-search', {
method: 'POST',
body: { q: trimmedQuery.value },
signal: controller.signal,
})
if (controller.signal.aborted)
return
taskSearch.value = answer._tag === 'found' ? { _tag: 'found', skills: answer.items } : answer
}
catch (error) {
// An edit to the query aborts the request; the new query starts idle.
if (controller.signal.aborted)
return
console.warn('[search] task search unavailable', error)
taskSearch.value = { _tag: 'failed' }
}
finally {
if (taskInFlight === controller)
taskInFlight = null
}
}

// Invalidate on input, before debounce. Old results must never remain selectable.
watch(trimmedQuery, () => {
activeIndex.value = -1
Expand All @@ -282,6 +335,9 @@ function useSkillSearchInternal() {
serverResults.value = null
serverError.value = null
pending.value = false
taskInFlight?.abort()
taskInFlight = null
taskSearch.value = { _tag: 'idle' }
}, { flush: 'sync' })

const state = computed<SearchState>(() => {
Expand Down Expand Up @@ -349,9 +405,19 @@ function useSkillSearchInternal() {
}
}

// A sentence can ask task search for Skills that fit. Found Skills take the
// search results' place; every other status leaves them where they are.
const task = taskSearch.value
if (server.kind === 'intent' && task._tag === 'found') {
const taskRows: SearchRow[] = task.skills.map(skill => ({ _tag: 'skill' as const, skill, provisional: false }))
return { _tag: 'ready', rows: [...taskRows, { _tag: 'all', query: term }], total: server.total, repository: null }
}

const rows: SearchRow[] = [...localRepositoryRows.value, ...skillRows]
if (skillRows.length)
rows.push({ _tag: 'all', query: term })
if (server.kind === 'intent' && task._tag !== 'found')
rows.push({ _tag: 'task', status: task })
return { _tag: 'ready', rows, total: server.total, mode: server.mode ?? undefined, repository: null }
})

Expand Down Expand Up @@ -490,6 +556,8 @@ function useSkillSearchInternal() {
loadTypeaheadIndex,
retry: () => runSearch(trimmedQuery.value),
submitRepository,
taskSearch,
findForTask,
skillKey,
}
}
Expand Down
6 changes: 6 additions & 0 deletions app/composables/useSkillSearchBox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function useSkillSearchBox(options: { inputs: () => (HTMLInputElement | n
rememberQuery,
loadTypeaheadIndex,
submitRepository,
findForTask,
} = search

const keyboardSelection = ref(false)
Expand Down Expand Up @@ -76,6 +77,11 @@ export function useSkillSearchBox(options: { inputs: () => (HTMLInputElement | n
await goToResults()
return
}
// Task search answers inside the panel, so the panel stays open.
if (row._tag === 'task') {
await findForTask()
return
}
if (state.value._tag !== 'repository')
rememberQuery(trimmedQuery.value)
dismiss()
Expand Down
15 changes: 13 additions & 2 deletions layers/marketing/content/pages/privacy.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: Privacy
description: What skilld.dev stores, why it needs the data, how long it keeps it, and how you delete your account.
label: Last updated 1 October 2026
updatedAt: 2026-10-01
label: Last updated 6 October 2026
updatedAt: 2026-10-06
---

## In short
Expand Down Expand Up @@ -65,6 +65,16 @@ Your browser keeps your color mode and your 5 most recent searches in local stor

Search sends your query to Workers AI on Cloudflare to find matching Skills. skilld stores no copy of the query text. It caches the query's embedding, a list of numbers, for up to one day. The cache entry holds nothing that identifies you.

### Task search

When you search with a sentence, the search panel offers "Find skills for this task". The search box sends nothing more until you select it.

When you select it, skilld sends your search text to the GPT-6 Luna model from [OpenAI](https://openai.com), through Cloudflare. The model writes a few searches, skilld runs them on its own registry, and the model picks Skills from those results. OpenAI receives your search text and those results. It receives no IP address, cookie, or account details.

skilld caches the answer, a list of Skills, for up to 7 days. The cache key is a hash of your search text. skilld logs how long each answer took, how many tokens it used, and what it cost. The log holds no search text.

Each task search costs money, so Cloudflare counts task searches per IP address over one minute. skilld stores no IP address.

### Images

Every avatar and every image in a Skill loads through skilld.dev. skilld.dev fetches the image from GitHub, X, Bluesky, or the host the Skill names, and sends none of your request details. Those hosts never see your IP address, and your browser never contacts them for an image. skilld.dev also sends a referrer policy that shares only the site origin with other sites you open.
Expand Down Expand Up @@ -130,6 +140,7 @@ skilld uses [Sentry](https://sentry.io) to find and fix errors.
- Cloudflare hosts skilld.dev and runs its database, cache, email delivery, image proxy, Web Analytics, usage counts, Workers AI search, and request logs. It handles requests, account data, email addresses, analytics reports, and search queries.
- GitHub handles sign-in and holds the source of every Skill. It handles your GitHub profile and tokens.
- Sentry receives error reports, with the data listed above removed.
- OpenAI runs the model behind task search. Through Cloudflare, it receives the search text of a task search and the registry results, and nothing that identifies you.

## How long skilld keeps data

Expand Down
Loading