import { packageStorage } from 'kody:runtime'
/** Stay well under the ~1s context/search retriever budget. */
const STORAGE_BUDGET_MS = 250
export type SkillSearchInput = {
query?: string
scope?: 'search' | 'context'
limit?: number
memoryContext?: Record<string, unknown> | null
conversationId?: string | null
}
export type SkillSearchResult = {
id: string
title: string
summary: string
details?: string
score?: number
source?: string
metadata?: Record<string, unknown>
}
type SkillSearchRow = {
id: string
name: string
description: string
updated_at: string
}
function tokenize(query: string): string[] {
return query
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((term) => term.length > 1)
}
async function queryRows(sql: string): Promise<SkillSearchRow[] | null> {
const query = packageStorage().sql(sql)
let timer: ReturnType<typeof setTimeout> | undefined
try {
const raced = await Promise.race([
query.then(
(result) =>
({
ok: true as const,
rows: (result.rows ?? []) as SkillSearchRow[],
}) as const,
),
new Promise<{ ok: false }>((resolve) => {
timer = setTimeout(() => resolve({ ok: false }), STORAGE_BUDGET_MS)
}),
])
if (!raced.ok) return null
return raced.rows
} catch {
return null
} finally {
if (timer !== undefined) clearTimeout(timer)
}
}
async function loadSkillRows(): Promise<SkillSearchRow[]> {
// Prefer the repo-backed index projection; fall back to legacy skills table.
// No ensureSchema / DDL here — retriever storage is read-only.
const indexed = await queryRows(
`SELECT id, name, description, updated_at
FROM skills_index
ORDER BY id ASC`,
)
if (indexed && indexed.length > 0) return indexed
const legacy = await queryRows(
`SELECT id, name, description, updated_at
FROM skills
ORDER BY id ASC`,
)
return legacy ?? []
}
/**
* Package retriever: surface skill documents from the registry in Kody search
* and automatic context retrieval. Matches query terms case-insensitively
* against skill name + description; returns all skills for empty/broad queries.
* Read-only — the retriever runtime binds storage without write access.
*
* Document content lives in the plain `skills` repo (after migration). This
* retriever reads the fast `skills_index` projection (or legacy `skills` table)
* with a ~250ms budget and soft-fails to `{ results: [] }` so the ~1s
* context/search budget is never burned.
*/
export default async function skillSearch(
input: SkillSearchInput = {},
): Promise<{ results: SkillSearchResult[] }> {
const scope = input.scope === 'context' ? 'context' : 'search'
const defaultLimit = scope === 'context' ? 2 : 5
const limit = Math.max(
1,
Math.min(20, Number(input.limit ?? defaultLimit) || defaultLimit),
)
const rows = await loadSkillRows()
const terms = tokenize(String(input.query ?? ''))
const scored = rows
.map((row) => {
const haystack = `${row.name} ${row.description}`.toLowerCase()
const hits = terms.filter((term) => haystack.includes(term)).length
return { row, hits }
})
.filter(({ hits }) => terms.length === 0 || hits > 0)
.sort(
(left, right) =>
right.hits - left.hits || left.row.id.localeCompare(right.row.id),
)
.slice(0, limit)
return {
results: scored.map(({ row, hits }) => {
const fetchWith = `skill_get({ id: '${row.id}' })`
const base = {
id: row.id,
title: row.name,
summary: row.description,
score: terms.length === 0 ? undefined : hits / terms.length,
source: 'skills registry',
metadata: {
skill_id: row.id,
updated_at: row.updated_at,
fetch_with: fetchWith,
},
}
if (scope === 'context') {
return base
}
return {
...base,
details: `Reusable skill document in the @kody/skills registry. Load full content with ${fetchWith} or import skillGet from 'kody:@kody/skills/skill-get'.`,
}
}),
}
}