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 | null conversationId?: string | null } export type SkillSearchResult = { id: string title: string summary: string details?: string score?: number source?: string metadata?: Record } 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 { const query = packageStorage().sql(sql) let timer: ReturnType | 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 { // 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'.`, } }), } }