Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/skills

src/skill-search.ts

142 lines · 3.8 KB · TypeScript
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'.`,
			}
		}),
	}
}