fix(web): harden search relevance UX (#2206)

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Vyctor H. Brzezowski
2026-05-14 11:18:42 -07:00
committed by GitHub
co-authored by Patrick Erichsen
parent cccef81a3e
commit 9ab92e5847
21 changed files with 1382 additions and 196 deletions
+3
View File
@@ -23,6 +23,9 @@
### Fixes
- Web/API: keep search results limited to items with match evidence, preserve
trust and popularity as tie-breakers, and show `N+` counts without exact
count queries (#2206) (thanks @vyctorbrzezowski).
- API: return `400` for invalid known public package filters and invalid skill
list sort values, while continuing to ignore unknown query parameters (#2184).
- API/docs: document v1 plain-text error responses and expose owner metadata in
+39
View File
@@ -3989,6 +3989,45 @@ describe("httpApiV1 handlers", () => {
expect(runQuery).not.toHaveBeenCalled();
});
it("plugins search sorts by rank tier before score without exposing rank metadata", async () => {
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return [
{
score: 20,
rankTier: 3,
package: makeCatalogItem("summary-plugin", { family: "code-plugin", updatedAt: 100 }),
},
];
}
if (args.family === "bundle-plugin") {
return [
{
score: 10,
rankTier: 1,
package: makeCatalogItem("name-plugin", { family: "bundle-plugin", updatedAt: 50 }),
},
];
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const runMutation = vi.fn().mockResolvedValue(okRate());
const response = await __handlers.pluginsGetRouterV1Handler(
makeCtx({ runQuery, runMutation }),
new Request("https://example.com/api/v1/plugins/search?q=plugin&limit=2"),
);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.results.map((entry: { package: { name: string } }) => entry.package.name)).toEqual([
"name-plugin",
"summary-plugin",
]);
expect(body.results[0]).not.toHaveProperty("rankTier");
expect(body.results[0]).not.toHaveProperty("matchReason");
});
it("packages list forwards viewerUserId for authenticated private package browsing", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
const runQuery = vi.fn().mockResolvedValue({ page: [], isDone: true, continueCursor: "" });
+29 -13
View File
@@ -69,7 +69,6 @@ const apiRefs = api as unknown as {
};
skills: {
listPackageCatalogPage: unknown;
searchPackageCatalogPublic: unknown;
getBySlug: unknown;
listVersionsPage: unknown;
getVersionBySkillAndVersion: unknown;
@@ -116,6 +115,7 @@ const internalRefs = internal as unknown as {
};
skills: {
getSkillBySlugInternal: unknown;
searchPackageCatalogForHttpInternal: unknown;
getVersionByIdInternal: unknown;
getVersionBySkillAndVersionInternal: unknown;
};
@@ -691,7 +691,11 @@ type CatalogListItem = {
verificationTier?: string | null;
};
type CatalogSearchEntry = { score: number; package: CatalogListItem };
type CatalogSearchEntry = {
score: number;
rankTier?: number;
package: CatalogListItem;
};
type CatalogSourceCursorState = {
cursor: string | null;
@@ -892,12 +896,20 @@ function compareCatalogItems(a: CatalogListItem, b: CatalogListItem) {
function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
return (
(a.rankTier ?? Number.POSITIVE_INFINITY) - (b.rankTier ?? Number.POSITIVE_INFINITY) ||
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
compareCatalogItems(a.package, b.package)
);
}
function toPublicCatalogSearchEntry(entry: CatalogSearchEntry) {
return {
score: entry.score,
package: entry.package,
};
}
async function searchPackageCatalog(
ctx: ActionCtx,
args: {
@@ -2155,7 +2167,7 @@ async function searchPackages(
if (family === "skill") {
results = await runQueryRef<CatalogSearchEntry[]>(
ctx,
apiRefs.skills.searchPackageCatalogPublic,
internalRefs.skills.searchPackageCatalogForHttpInternal,
{
query: queryText,
limit,
@@ -2222,15 +2234,19 @@ async function searchPackages(
category,
viewerUserId: viewerUserId ?? undefined,
}),
runQueryRef<CatalogSearchEntry[]>(ctx, apiRefs.skills.searchPackageCatalogPublic, {
query: queryText,
limit,
channel: channelParam.value,
isOfficial: isOfficial.value,
highlightedOnly: highlightedOnly || undefined,
executesCode: executesCode.value,
capabilityTag,
}),
runQueryRef<CatalogSearchEntry[]>(
ctx,
internalRefs.skills.searchPackageCatalogForHttpInternal,
{
query: queryText,
limit,
channel: channelParam.value,
isOfficial: isOfficial.value,
highlightedOnly: highlightedOnly || undefined,
executesCode: executesCode.value,
capabilityTag,
},
),
]);
const seen = new Set<string>();
results = [...packageResults, ...skillResults]
@@ -2243,7 +2259,7 @@ async function searchPackages(
.sort(compareCatalogSearchEntries)
.slice(0, limit);
}
return json({ results }, 200, rate.headers);
return json({ results: results.map(toPublicCatalogSearchEntry) }, 200, rate.headers);
}
export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
+18 -1
View File
@@ -1,7 +1,12 @@
/* @vitest-environment node */
import { describe, expect, it } from "vitest";
import { __test, matchesExactTokens, tokenize } from "./searchText";
import {
__test,
matchesExactTokens,
matchesExploratoryTokenPrefixes,
tokenize,
} from "./searchText";
describe("searchText", () => {
it("tokenize lowercases and splits on punctuation", () => {
@@ -45,6 +50,18 @@ describe("searchText", () => {
expect(matchesExactTokens(["token"], [" ", null, undefined])).toBe(false);
});
it("requires every query token to meet the exploratory minimum", () => {
expect(matchesExploratoryTokenPrefixes(tokenize("postgres"), ["Postgres database"], 3)).toBe(
true,
);
expect(matchesExploratoryTokenPrefixes(tokenize("ai postgres"), ["Postgres database"], 3)).toBe(
false,
);
expect(matchesExploratoryTokenPrefixes(tokenize("pg database"), ["Database tools"], 3)).toBe(
false,
);
});
it("normalize uses lowercase", () => {
expect(__test.normalize("AbC")).toBe("abc");
});
+34 -3
View File
@@ -138,17 +138,48 @@ export function matchesExactTokens(
queryTokens: string[],
parts: Array<string | null | undefined>,
): boolean {
if (queryTokens.length === 0) return false;
return matchesTokenPrefixes(queryTokens, parts);
}
export function matchesTokenPrefixes(
queryTokens: string[],
parts: Array<string | null | undefined>,
options: { minQueryTokenLength?: number } = {},
): boolean {
const minQueryTokenLength = options.minQueryTokenLength ?? 1;
const eligibleQueryTokens = queryTokens.filter((token) => token.length >= minQueryTokenLength);
if (eligibleQueryTokens.length === 0) return false;
const text = parts.filter((part) => Boolean(part?.trim())).join(" ");
if (!text) return false;
const textTokens = tokenize(text);
if (textTokens.length === 0) return false;
// Require every query token to prefix-match so partial matches do not crowd out better results.
return queryTokens.every((queryToken) =>
// Require every eligible query token to prefix-match so partial matches do not crowd out better results.
return eligibleQueryTokens.every((queryToken) =>
textTokens.some((textToken) => textToken.startsWith(queryToken)),
);
}
export function matchesExploratoryTokenPrefixes(
queryTokens: string[],
parts: Array<string | null | undefined>,
minQueryTokenLength: number,
): boolean {
if (queryTokens.length === 0) return false;
if (!queryTokens.every((token) => token.length >= minQueryTokenLength)) return false;
return matchesTokenPrefixes(queryTokens, parts, { minQueryTokenLength });
}
export function matchesAllTokens(
queryTokens: string[],
candidateTokens: string[],
matcher: (candidate: string, query: string) => boolean,
) {
if (queryTokens.length === 0 || candidateTokens.length === 0) return false;
return queryTokens.every((queryToken) =>
candidateTokens.some((candidateToken) => matcher(candidateToken, queryToken)),
);
}
export const __test = {
normalize,
detectCJKLanguage,
+130
View File
@@ -1824,6 +1824,136 @@ describe("packages public queries", () => {
expect(take).toHaveBeenCalledWith(50);
});
it("does not let official status make unrelated packages eligible for search", async () => {
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("openclaw-nostr", {
displayName: "OpenClaw Nostr",
isOfficial: true,
summary: "Protocol integration.",
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "zzzznonexistentquery123",
limit: 10,
});
expect(result).toEqual([]);
});
it("does not treat punctuation-only queries as package matches", async () => {
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("openclaw-nostr", {
isOfficial: true,
runtimeId: "openclaw.nostr",
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: ".",
limit: 10,
});
expect(result).toEqual([]);
});
it("does not match short queries through arbitrary summary substrings", async () => {
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("local-tools", {
summary: "Available helper tools.",
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "ai",
limit: 10,
});
expect(result).toEqual([]);
});
it("does not drop short tokens from exploratory package matches", async () => {
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("database-tools", {
summary: "Postgres database helper.",
capabilityTags: ["postgres"],
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "ai postgres",
limit: 10,
});
expect(result).toEqual([]);
});
it("orders lexical matches before summary-only matches without exposing rank metadata", async () => {
const { ctx } = makeDigestCtx({
pages: [
{
page: [
makeDigest("official-helper", {
displayName: "Official Helper",
isOfficial: true,
summary: "Ghost CMS integration.",
updatedAt: 100,
}),
makeDigest("ghost-tools", {
displayName: "Ghost Tools",
isOfficial: false,
summary: "CMS helper.",
updatedAt: 1,
}),
],
isDone: true,
continueCursor: "",
},
],
});
const result = await searchPublicHandler(ctx, {
query: "ghost",
limit: 10,
});
expect(result.map((entry) => entry.package.name)).toEqual(["ghost-tools", "official-helper"]);
expect(result[0]).not.toHaveProperty("rankTier");
expect(result[0]).not.toHaveProperty("matchReason");
});
it("allows org collaborators to search their private packages", async () => {
const { ctx } = makeDigestCtx({
capabilityPages: [
+105 -43
View File
@@ -80,7 +80,7 @@ import {
MAX_PUBLISH_TOTAL_BYTES,
} from "./lib/publishLimits";
import { MAX_ACTIVE_REPORTS_PER_USER, MAX_REPORT_REASON_LENGTH } from "./lib/reporting";
import { tokenize } from "./lib/searchText";
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
import { hashSkillFiles } from "./lib/skills";
import { runStaticPublishScan } from "./lib/staticPublishScan";
@@ -975,31 +975,102 @@ async function getOptionalViewerUserId(ctx: QueryCtx | MutationCtx) {
return await getOptionalActiveAuthUserId(ctx);
}
function packageSearchScore(digest: PackageDigestLike, queryText: string) {
const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3;
type PackageSearchMatch = {
rankTier: number;
score: number;
};
function packageSearchMatch(
digest: PackageDigestLike,
queryText: string,
): PackageSearchMatch | null {
const needle = queryText.toLowerCase();
const queryTokens = tokenize(queryText);
if (queryTokens.length === 0) return null;
const normalized = digest.normalizedName.toLowerCase();
const display = digest.displayName.toLowerCase();
const runtimeId = digest.runtimeId?.toLowerCase() ?? "";
const summary = (digest.summary ?? "").toLowerCase();
const nameTokens = tokenize(normalized);
const displayTokens = tokenize(display);
const runtimeTokens = tokenize(runtimeId);
let score = 0;
if (normalized === needle) score += 200;
else if (normalized.startsWith(needle)) score += 120;
else if (normalized.includes(needle)) score += 80;
let rankTier = Number.POSITIVE_INFINITY;
if (display === needle) score += 150;
else if (display.startsWith(needle)) score += 70;
else if (display.includes(needle)) score += 40;
const setMatch = (tier: number, boost: number) => {
score += boost;
rankTier = Math.min(rankTier, tier);
};
if (runtimeId === needle) score += 180;
else if (runtimeId.startsWith(needle)) score += 90;
else if (runtimeId.includes(needle)) score += 45;
if (normalized === needle) setMatch(0, 200);
else if (normalized.startsWith(needle)) setMatch(1, 120);
else if (normalized.includes(needle)) setMatch(1, 80);
if (summary.includes(needle)) score += 20;
if ((digest.capabilityTags ?? []).some((entry) => entry.toLowerCase().includes(needle))) {
score += 12;
if (display === needle) setMatch(0, 150);
else if (display.startsWith(needle)) setMatch(1, 70);
else if (display.includes(needle)) setMatch(1, 40);
if (runtimeId === needle) setMatch(0, 180);
else if (runtimeId.startsWith(needle)) setMatch(1, 90);
else if (runtimeId.includes(needle)) setMatch(1, 45);
if (
matchesAllTokens(
queryTokens,
[...nameTokens, ...displayTokens, ...runtimeTokens],
(a, b) => a === b,
)
) {
setMatch(1, 65);
} else if (
matchesAllTokens(queryTokens, [...nameTokens, ...displayTokens, ...runtimeTokens], (a, b) =>
a.startsWith(b),
)
) {
setMatch(1, 35);
}
if (digest.isOfficial) score += 5;
return score;
if (
matchesExploratoryTokenPrefixes(
queryTokens,
[digest.summary],
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
)
) {
setMatch(3, 20);
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
digest.capabilityTags ?? [],
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
)
) {
setMatch(2, 12);
}
if (!Number.isFinite(rankTier)) return null;
return { rankTier, score };
}
function comparePackageSearchMatches<
T extends PackageSearchMatch & { package: Pick<PackageDigestLike, "isOfficial" | "updatedAt"> },
>(a: T, b: T) {
return (
a.rankTier - b.rankTier ||
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt
);
}
function toPublicPackageSearchEntry(
entry: PackageSearchMatch & { package: PublicPackageListItem },
) {
return {
score: entry.score,
package: entry.package,
};
}
function prefixUpperBound(value: string) {
@@ -2153,7 +2224,7 @@ export const searchPublic = query({
category: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await searchPackagesImpl(ctx, args);
return (await searchPackagesImpl(ctx, args)).map(toPublicPackageSearchEntry);
},
});
@@ -2206,20 +2277,18 @@ async function searchPackagesImpl(
if (args.highlightedOnly) {
const digests = await fetchHighlightedPackageDigests(ctx, args);
return digests
.map((digest) => ({
score: packageSearchScore(digest, queryText),
package: digest,
}))
.filter((entry) => entry.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt,
.map((digest) => {
const match = packageSearchMatch(digest, queryText);
return match ? { ...match, package: digest } : null;
})
.filter((entry): entry is PackageSearchMatch & { package: PackageDigestLike } =>
Boolean(entry),
)
.sort(comparePackageSearchMatches)
.slice(0, targetCount)
.map((entry) => ({
score: entry.score,
rankTier: entry.rankTier,
package: toPublicPackageListItem(entry.package),
}));
}
@@ -2248,7 +2317,7 @@ async function searchPackagesImpl(
isOfficial: args.isOfficial,
executesCode: args.executesCode,
});
const matches: Array<{ score: number; package: PublicPackageListItem }> = [];
const matches: Array<PackageSearchMatch & { package: PublicPackageListItem }> = [];
const seen = new Set<string>();
const directDigests =
args.capabilityTag || args.category
@@ -2257,11 +2326,11 @@ async function searchPackagesImpl(
for (const digest of directDigests) {
if (!(await canViewPackage(digest))) continue;
if (!digestMatchesSearchFilters(digest, args)) continue;
const score = packageSearchScore(digest, queryText);
if (score <= 0 || seen.has(digest.packageId)) continue;
const match = packageSearchMatch(digest, queryText);
if (!match || seen.has(digest.packageId)) continue;
seen.add(digest.packageId);
matches.push({
score,
...match,
package: toPublicPackageListItem(digest),
});
}
@@ -2275,25 +2344,18 @@ async function searchPackagesImpl(
for (const digest of digests) {
if (!(await canViewPackage(digest))) continue;
if (!digestMatchesSearchFilters(digest, args)) continue;
const score = packageSearchScore(digest, queryText);
if (score <= 0 || seen.has(digest.packageId)) continue;
const match = packageSearchMatch(digest, queryText);
if (!match || seen.has(digest.packageId)) continue;
seen.add(digest.packageId);
matches.push({
score,
...match,
package: toPublicPackageListItem(digest),
});
if (matches.length >= targetCount) break;
}
}
return matches
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt,
)
.slice(0, targetCount);
return matches.sort(comparePackageSearchMatches).slice(0, targetCount);
}
export const getPackageByNameInternal = internalQuery({
+81
View File
@@ -579,6 +579,87 @@ describe("search helpers", () => {
expect(result.some((entry) => entry.skill.slug === "antigravity-image-generator")).toBe(true);
});
it("orders lexical name matches above summary-only matches before popularity", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
const exactName = {
skill: makePublicSkill({
id: "skills:postgres",
slug: "postgres",
displayName: "Postgres",
downloads: 0,
}),
version: null,
ownerHandle: "owner",
owner: null,
};
const summaryOnly = {
skill: {
...makePublicSkill({
id: "skills:database-tools",
slug: "database-tools",
displayName: "Database Tools",
downloads: 1_000_000_000,
}),
summary: "Postgres database helper.",
},
version: null,
ownerHandle: "owner",
owner: null,
};
const runQuery = vi
.fn()
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
.mockResolvedValueOnce([]) // directPrefixSkillMatches
.mockResolvedValueOnce([summaryOnly, exactName]); // lexicalFallbackSkills
const result = await searchSkillsHandler(
{
vectorSearch: vi.fn().mockResolvedValue([]),
runQuery,
},
{ query: "postgres", limit: 2 },
);
expect(result.map((entry) => entry.skill.slug)).toEqual(["postgres", "database-tools"]);
expect(result[0]).not.toHaveProperty("rankTier");
expect(result[0]).not.toHaveProperty("matchReason");
});
it("does not let vector recall make short summary-only skills eligible", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
const summaryOnly = {
embeddingId: "skillEmbeddings:ai",
skill: {
...makePublicSkill({
id: "skills:ai-summary",
slug: "general-helper",
displayName: "General Helper",
downloads: 1_000,
}),
summary: "AI helper for teams.",
},
version: null,
ownerHandle: "owner",
owner: null,
};
const runQuery = vi
.fn()
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
.mockResolvedValueOnce([]) // directPrefixSkillMatches
.mockResolvedValueOnce([summaryOnly]) // hydrateResults
.mockResolvedValueOnce([]); // lexicalFallbackSkills
const result = await searchSkillsHandler(
{
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:ai", _score: 0.99 }]),
runQuery,
},
{ query: "ai", limit: 10 },
);
expect(result).toEqual([]);
});
it("always includes an exact slug match even when vector exact matches already fill the limit", async () => {
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
+81 -18
View File
@@ -8,7 +8,12 @@ import { generateEmbedding } from "./lib/embeddings";
import type { HydratableSkill, PublicPublisher } from "./lib/public";
import { toPublicPublisher, toPublicSkill, toPublicSoul } from "./lib/public";
import { getOwnerPublisher } from "./lib/publishers";
import { matchesExactTokens, tokenize } from "./lib/searchText";
import {
matchesAllTokens,
matchesExactTokens,
matchesExploratoryTokenPrefixes,
tokenize,
} from "./lib/searchText";
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
import { isSkillSuspicious } from "./lib/skillSafety";
import {
@@ -50,7 +55,17 @@ type SkillSearchEntry = {
owner: PublicPublisher | null;
};
type SearchResult = SkillSearchEntry & { score: number };
type SearchMatch = {
rankTier: number;
};
type SearchResult = SkillSearchEntry &
SearchMatch & {
score: number;
};
type PublicSearchResult = SkillSearchEntry & {
score: number;
};
const EXACT_SLUG_BOOST = 2.5;
const SLUG_TOKEN_BOOST = 1.4;
@@ -66,6 +81,7 @@ const MAX_DIRECT_SKILL_SEARCH_CANDIDATES = 100;
const MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES = 40;
const MIN_VECTOR_SEARCH_CANDIDATES = 50;
const MAX_VECTOR_SEARCH_CANDIDATES = 128;
const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3;
const SKILL_CAPABILITY_TAG_SET = new Set<string>(SKILL_CAPABILITY_TAGS);
function getNextCandidateLimit(current: number, max: number) {
@@ -73,17 +89,6 @@ function getNextCandidateLimit(current: number, max: number) {
return next > current ? next : null;
}
function matchesAllTokens(
queryTokens: string[],
candidateTokens: string[],
matcher: (candidate: string, query: string) => boolean,
) {
if (queryTokens.length === 0 || candidateTokens.length === 0) return false;
return queryTokens.every((queryToken) =>
candidateTokens.some((candidateToken) => matcher(candidateToken, queryToken)),
);
}
function getLexicalBoost(queryTokens: string[], displayName: string, slug: string) {
const slugTokens = tokenize(slug);
const nameTokens = tokenize(displayName);
@@ -123,6 +128,54 @@ function scoreSkillResult(
return vectorScore + lexicalBoost + popularityBoost;
}
function classifySkillMatch(
query: string,
queryTokens: string[],
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "capabilityTags">,
): SearchMatch | null {
const needle = query.toLowerCase();
const normalizedSlugQuery = queryTokens.join("-");
const slug = skill.slug.toLowerCase();
const display = skill.displayName.toLowerCase();
const slugTokens = tokenize(slug);
const displayTokens = tokenize(display);
if (slug === normalizedSlugQuery || slug === needle || display === needle) {
return { rankTier: 0 };
}
if (slug.startsWith(normalizedSlugQuery) || slug.startsWith(needle)) {
return { rankTier: 1 };
}
if (display.startsWith(needle)) {
return { rankTier: 1 };
}
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a === b)) {
return { rankTier: 1 };
}
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a.startsWith(b))) {
return { rankTier: 1 };
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
skill.capabilityTags ?? [],
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
)
) {
return { rankTier: 2 };
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
[skill.summary],
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
)
) {
return { rankTier: 3 };
}
return null;
}
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
if (fallback.length === 0) return primary;
const out = [...primary];
@@ -163,7 +216,7 @@ export const searchSkills: ReturnType<typeof action> = action({
nonSuspiciousOnly: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
},
handler: async (ctx, args): Promise<SearchResult[]> => {
handler: async (ctx, args): Promise<PublicSearchResult[]> => {
const query = args.query.trim();
if (!query) return [];
if (args.capabilityTag && !SKILL_CAPABILITY_TAG_SET.has(args.capabilityTag)) return [];
@@ -284,11 +337,14 @@ export const searchSkills: ReturnType<typeof action> = action({
})) as SkillSearchEntry[]);
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches);
return mergedMatches
.map((entry) => {
const rankedMatches = mergedMatches
.map((entry): SearchResult | null => {
const vectorScore = entry.embeddingId ? (scoreById.get(entry.embeddingId) ?? 0) : 0;
const match = classifySkillMatch(query, queryTokens, entry.skill);
if (!match) return null;
return {
...entry,
...match,
score: scoreSkillResult(
queryTokens,
vectorScore,
@@ -298,9 +354,15 @@ export const searchSkills: ReturnType<typeof action> = action({
),
};
})
.filter((entry) => entry.skill)
.sort((a, b) => b.score - a.score || b.skill.stats.downloads - a.skill.stats.downloads)
.filter((entry): entry is SearchResult => Boolean(entry?.skill))
.sort(
(a, b) =>
a.rankTier - b.rankTier ||
b.score - a.score ||
b.skill.stats.downloads - a.skill.stats.downloads,
)
.slice(0, limit);
return rankedMatches.map(({ rankTier: _rankTier, ...entry }) => entry);
},
});
@@ -897,6 +959,7 @@ export const __test = {
matchesAllTokens,
getLexicalBoost,
scoreSkillResult,
classifySkillMatch,
mergeUniqueBySkillId,
mergeUniqueBySoulId,
};
+106
View File
@@ -218,6 +218,112 @@ describe("skills package catalog queries", () => {
expect(result[0]?.score).toBeGreaterThan(0);
});
it("does not let official status make unrelated skills eligible for package search", async () => {
const result = await searchPackageCatalogPublicHandler(
makeCtx([
{
page: [
makeDigest("official-skill", {
badges: { official: { byUserId: "users:admin", at: 1 } },
displayName: "Official Skill",
summary: "General integration.",
}),
],
isDone: true,
continueCursor: "",
},
]),
{
query: "zzzznonexistentquery123",
limit: 5,
},
);
expect(result).toEqual([]);
});
it("returns skill package match metadata and orders name matches before summary matches", async () => {
const result = await searchPackageCatalogPublicHandler(
makeCtx([
{
page: [
makeDigest("official-helper", {
badges: { official: { byUserId: "users:admin", at: 1 } },
displayName: "Official Helper",
summary: "Ghost CMS integration.",
updatedAt: 100,
}),
makeDigest("ghost-tools", {
displayName: "Ghost Tools",
summary: "CMS helper.",
updatedAt: 1,
}),
],
isDone: true,
continueCursor: "",
},
]),
{
query: "ghost",
limit: 5,
},
);
expect(result.map((entry) => entry.package.name)).toEqual(["ghost-tools", "official-helper"]);
expect(result[0]).not.toHaveProperty("rankTier");
expect(result[0]).not.toHaveProperty("matchReason");
});
it("uses capability tags as skill package search evidence", async () => {
const result = await searchPackageCatalogPublicHandler(
makeCtx([
{
page: [
makeDigest("wallet-helper", {
displayName: "Wallet Helper",
summary: "Payment helper.",
capabilityTags: ["crypto", "requires-wallet"],
}),
makeDigest("weather"),
],
isDone: true,
continueCursor: "",
},
]),
{
query: "crypto",
limit: 5,
},
);
expect(result.map((entry) => entry.package.name)).toEqual(["wallet-helper"]);
expect(result[0]).not.toHaveProperty("rankTier");
});
it("does not drop short tokens from exploratory skill package matches", async () => {
const result = await searchPackageCatalogPublicHandler(
makeCtx([
{
page: [
makeDigest("database-tools", {
displayName: "Database Tools",
summary: "Postgres database helper.",
capabilityTags: ["postgres"],
}),
],
isDone: true,
continueCursor: "",
},
]),
{
query: "ai postgres",
limit: 5,
},
);
expect(result).toEqual([]);
});
it("filters skills by capability tag", async () => {
const result = await listPackageCatalogPageHandler(
makeCtx([
+157 -65
View File
@@ -97,6 +97,7 @@ import {
reserveSlugForHardDeleteFinalize,
upsertReservedSlugForRightfulOwner,
} from "./lib/reservedSlugs";
import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText";
import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags";
import { normalizeSkillIconValue } from "./lib/skillIcon";
import {
@@ -4671,23 +4672,81 @@ function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkill
};
}
function scoreSkillCatalogResult(digest: Doc<"skillSearchDigest">, queryText: string) {
const EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH = 3;
type SkillCatalogSearchMatch = {
rankTier: number;
score: number;
};
function skillCatalogSearchMatch(
digest: Doc<"skillSearchDigest">,
queryText: string,
): SkillCatalogSearchMatch | null {
const needle = queryText.toLowerCase();
const queryTokens = tokenize(queryText);
if (queryTokens.length === 0) return null;
const slug = digest.slug.toLowerCase();
const display = digest.displayName.toLowerCase();
const summary = (digest.summary ?? "").toLowerCase();
const slugTokens = tokenize(slug);
const displayTokens = tokenize(display);
let score = 0;
if (slug === needle) score += 200;
else if (slug.startsWith(needle)) score += 120;
else if (slug.includes(needle)) score += 80;
let rankTier = Number.POSITIVE_INFINITY;
if (display === needle) score += 150;
else if (display.startsWith(needle)) score += 70;
else if (display.includes(needle)) score += 40;
const setMatch = (tier: number, boost: number) => {
score += boost;
rankTier = Math.min(rankTier, tier);
};
if (summary.includes(needle)) score += 20;
if (isSkillCatalogOfficial(digest)) score += 5;
return score;
if (slug === needle) setMatch(0, 200);
else if (slug.startsWith(needle)) setMatch(1, 120);
else if (slug.includes(needle)) setMatch(1, 80);
if (display === needle) setMatch(0, 150);
else if (display.startsWith(needle)) setMatch(1, 70);
else if (display.includes(needle)) setMatch(1, 40);
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a === b)) {
setMatch(1, 65);
} else if (
matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a.startsWith(b))
) {
setMatch(1, 35);
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
digest.capabilityTags ?? [],
EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH,
)
) {
setMatch(2, 12);
}
if (
matchesExploratoryTokenPrefixes(
queryTokens,
[digest.summary],
EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH,
)
) {
setMatch(3, 20);
}
if (!Number.isFinite(rankTier)) return null;
return { rankTier, score };
}
function compareSkillCatalogSearchMatches<
T extends SkillCatalogSearchMatch & {
package: Pick<PublicSkillCatalogItem, "isOfficial" | "updatedAt">;
},
>(a: T, b: T) {
return (
a.rankTier - b.rankTier ||
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt
);
}
function isKnownSkillCapabilityTag(tag: string | undefined) {
@@ -4785,6 +4844,76 @@ export const listPackageCatalogPage = query({
},
});
type SkillPackageCatalogSearchArgs = {
query: string;
limit?: number;
channel?: "official" | "community" | "private";
isOfficial?: boolean;
highlightedOnly?: boolean;
executesCode?: boolean;
capabilityTag?: string;
};
async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalogSearchArgs) {
const queryText = args.query.trim().toLowerCase();
if (!queryText) return [];
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) return [];
if (args.channel === "private" || args.executesCode === true) return [];
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
const matches: Array<SkillCatalogSearchMatch & { package: PublicSkillCatalogItem }> = [];
const seen = new Set<string>();
const exactSkill = await resolveSkillBySlugOrAlias(ctx, queryText);
if (exactSkill.skill) {
const exactDigest = await ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", exactSkill.skill!._id))
.unique();
if (exactDigest && skillCatalogMatchesFilters(exactDigest, args)) {
const match = skillCatalogSearchMatch(exactDigest, queryText);
if (match) {
seen.add(exactDigest.skillId);
matches.push({
...match,
package: toPublicSkillCatalogItem(exactDigest),
});
}
}
}
if (matches.length < targetCount) {
const pageSize = Math.min(MAX_SKILL_CATALOG_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
const page = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.paginate({ cursor: null, numItems: pageSize });
for (const digest of page.page) {
if (!skillCatalogMatchesFilters(digest, args)) continue;
const match = skillCatalogSearchMatch(digest, queryText);
if (!match || seen.has(digest.skillId)) continue;
seen.add(digest.skillId);
matches.push({
...match,
package: toPublicSkillCatalogItem(digest),
});
}
}
return matches.sort(compareSkillCatalogSearchMatches).slice(0, targetCount);
}
function toPublicSkillCatalogSearchEntry(
entry: SkillCatalogSearchMatch & { package: PublicSkillCatalogItem },
) {
return {
score: entry.score,
package: entry.package,
};
}
export const searchPackageCatalogPublic = query({
args: {
query: v.string(),
@@ -4798,61 +4927,24 @@ export const searchPackageCatalogPublic = query({
capabilityTag: v.optional(v.string()),
},
handler: async (ctx, args) => {
const queryText = args.query.trim().toLowerCase();
if (!queryText) return [];
if (args.capabilityTag && !isKnownSkillCapabilityTag(args.capabilityTag)) return [];
if (args.channel === "private" || args.executesCode === true) return [];
return (await searchPackageCatalogImpl(ctx, args)).map(toPublicSkillCatalogSearchEntry);
},
});
const targetCount = Math.max(1, Math.min(args.limit ?? 20, 100));
const matches: Array<{ score: number; package: PublicSkillCatalogItem }> = [];
const seen = new Set<string>();
const exactSkill = await resolveSkillBySlugOrAlias(ctx, queryText);
if (exactSkill.skill) {
const exactDigest = await ctx.db
.query("skillSearchDigest")
.withIndex("by_skill", (q) => q.eq("skillId", exactSkill.skill!._id))
.unique();
if (exactDigest && skillCatalogMatchesFilters(exactDigest, args)) {
const exactScore = scoreSkillCatalogResult(exactDigest, queryText);
if (exactScore > 0) {
seen.add(exactDigest.skillId);
matches.push({
score: exactScore,
package: toPublicSkillCatalogItem(exactDigest),
});
}
}
}
if (matches.length < targetCount) {
const pageSize = Math.min(MAX_SKILL_CATALOG_SEARCH_PAGE_SIZE, Math.max(targetCount * 5, 50));
const page = await ctx.db
.query("skillSearchDigest")
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
.order("desc")
.paginate({ cursor: null, numItems: pageSize });
for (const digest of page.page) {
if (!skillCatalogMatchesFilters(digest, args)) continue;
const score = scoreSkillCatalogResult(digest, queryText);
if (score <= 0 || seen.has(digest.skillId)) continue;
seen.add(digest.skillId);
matches.push({
score,
package: toPublicSkillCatalogItem(digest),
});
}
}
return matches
.sort(
(a, b) =>
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
b.package.updatedAt - a.package.updatedAt,
)
.slice(0, targetCount);
export const searchPackageCatalogForHttpInternal = internalQuery({
args: {
query: v.string(),
limit: v.optional(v.number()),
channel: v.optional(
v.union(v.literal("official"), v.literal("community"), v.literal("private")),
),
isOfficial: v.optional(v.boolean()),
highlightedOnly: v.optional(v.boolean()),
executesCode: v.optional(v.boolean()),
capabilityTag: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await searchPackageCatalogImpl(ctx, args);
},
});
+21
View File
@@ -0,0 +1,21 @@
# Search Relevance Contract
ClawHub search is a retrieval surface, not a browse fallback. A package, plugin, or skill can appear as a search match only when the query has evidence against that item:
- exact, prefix, or substring match in a navigational field such as name, slug, display name, normalized package name, or runtime id;
- token-prefix match in exploratory fields such as summary or capability tags, using a minimum query-token length for every query token to avoid short-query noise.
Trust and business signals are not relevance signals. `official`, verification tier, security status, downloads, stars, installs, highlighting, and recency may break ties between already eligible matches or appear as filters/badges, but they must not make an otherwise unrelated item eligible for search.
Search ranking should be lexicographic before it is numeric:
1. exact full field match in name, slug, normalized package name, or runtime id;
2. lexical field match in name, slug, normalized package name, display name, or runtime id;
3. capability or tag match;
4. summary match;
Numeric scores, trust state, popularity, and recency may order results inside those broad tiers, but must not move a weaker tier above a stronger tier.
The same contract applies across `/search`, the header typeahead, package/plugin catalog search, and skill-as-package catalog search.
Search result counts in the web UI should describe what is known from the current request. Do not label a page-size-limited result length as a total corpus count. Prefer `N+`, "shown", or no count unless an indexed/materialized total is available.
+11
View File
@@ -59,6 +59,8 @@ const defaultUnifiedSearchResult = {
],
skillCount: 1,
pluginCount: 1,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
};
@@ -319,8 +321,15 @@ describe("Header", () => {
const typeahead = screen.getByRole("listbox");
expect(within(typeahead).getByText("Skills")).toBeTruthy();
expect(screen.getByText("Weather Skill")).toBeTruthy();
expect(screen.getByText("@local / weather")).toBeTruthy();
expect(within(typeahead).getByText("Plugins")).toBeTruthy();
expect(screen.getByText("Weather Plugin")).toBeTruthy();
expect(input.getAttribute("role")).toBe("combobox");
expect(input.getAttribute("aria-autocomplete")).toBe("list");
expect(input.getAttribute("aria-expanded")).toBe("true");
const activeDescendant = input.getAttribute("aria-activedescendant");
expect(activeDescendant).toBeTruthy();
expect(document.getElementById(activeDescendant ?? "")).toBeTruthy();
expect(within(typeahead).queryByText("Publishers")).toBeNull();
expect(within(typeahead).queryByText('See user results for "weather"')).toBeNull();
@@ -379,6 +388,8 @@ describe("Header", () => {
pluginResults: [],
skillCount: 0,
pluginCount: 0,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
});
+162 -6
View File
@@ -56,6 +56,8 @@ describe("search route", () => {
pluginResults: [],
skillCount: 0,
pluginCount: 0,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
});
});
@@ -95,6 +97,8 @@ describe("search route", () => {
pluginResults: [{ type: "plugin", plugin: { name: "github-plugin" } }],
skillCount: 0,
pluginCount: 3,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
});
const route = await loadRoute();
@@ -123,6 +127,122 @@ describe("search route", () => {
});
it("can request more results from global search", async () => {
searchMock = { q: "weather", type: "skills" };
const skills = Array.from({ length: 25 }, (_, index) => ({
type: "skill",
skill: {
_id: `skill-${index}`,
slug: `weather-${index}`,
displayName: `Weather ${index}`,
ownerUserId: "users:1",
stats: { downloads: 0, stars: 0 },
updatedAt: 1,
createdAt: 1,
},
ownerHandle: "clawhub",
score: 1,
}));
useUnifiedSearchMock.mockReturnValue({
results: skills,
skillResults: skills,
pluginResults: [],
skillCount: 25,
pluginCount: 0,
skillHasMore: true,
pluginHasMore: false,
isSearching: false,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(useUnifiedSearchMock).toHaveBeenCalledWith("weather", "all", {
limits: { skills: 25, plugins: 25 },
});
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(useUnifiedSearchMock).toHaveBeenCalledWith("weather", "all", {
limits: { skills: 50, plugins: 50 },
});
});
it("keeps inactive tab counts honest while rendering the active tab", async () => {
searchMock = { q: "weather", type: "skills" };
useUnifiedSearchMock.mockReturnValue({
results: [
{
type: "skill",
skill: {
_id: "skill-weather",
slug: "weather",
displayName: "Weather",
ownerUserId: "users:1",
stats: { downloads: 0, stars: 0 },
updatedAt: 1,
createdAt: 1,
},
ownerHandle: "clawhub",
score: 1,
},
{ type: "plugin", plugin: { name: "weather-plugin" } },
],
skillResults: [
{
type: "skill",
skill: {
_id: "skill-weather",
slug: "weather",
displayName: "Weather",
ownerUserId: "users:1",
stats: { downloads: 0, stars: 0 },
updatedAt: 1,
createdAt: 1,
},
ownerHandle: "clawhub",
score: 1,
},
],
pluginResults: [{ type: "plugin", plugin: { name: "weather-plugin" } }],
skillCount: 1,
pluginCount: 1,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("button", { name: "All 2" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins 1" })).toBeTruthy();
expect(screen.getByText("weather")).toBeTruthy();
expect(screen.queryByText("weather-plugin")).toBeNull();
});
it("marks tab counts as partial when more results are available", async () => {
useUnifiedSearchMock.mockReturnValue({
results: [],
skillResults: [],
pluginResults: [{ type: "plugin", plugin: { name: "github-plugin" } }],
skillCount: 0,
pluginCount: 25,
skillHasMore: false,
pluginHasMore: true,
isSearching: false,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
expect(screen.getByRole("button", { name: "All 25+" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Plugins 25+" })).toBeTruthy();
});
it("does not show load more only because the current page is full", async () => {
searchMock = { q: "weather", type: "skills" };
useUnifiedSearchMock.mockReturnValue({
results: Array.from({ length: 25 }, (_, index) => ({
@@ -143,6 +263,8 @@ describe("search route", () => {
pluginResults: [],
skillCount: 25,
pluginCount: 0,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
});
const route = await loadRoute();
@@ -150,15 +272,49 @@ describe("search route", () => {
render(<Component />);
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "all", {
limits: { skills: 25, plugins: 25 },
});
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
it("links to skills browse when search has no matches", async () => {
searchMock = { q: "zzzz", type: "skills" };
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "all", {
limits: { skills: 50, plugins: 50 },
render(<Component />);
expect(screen.getByText('No matches for "zzzz"')).toBeTruthy();
expect(screen.getByRole("link", { name: "Show all skills" }).getAttribute("href")).toBe(
"/skills",
);
expect(screen.queryByRole("link", { name: "Show all plugins" })).toBeNull();
expect(screen.queryByRole("button", { name: "Search all types" })).toBeNull();
});
it("offers all-types recovery when the active type is empty but another type matched", async () => {
searchMock = { q: "weather", type: "skills" };
useUnifiedSearchMock.mockReturnValue({
results: [{ type: "plugin", plugin: { name: "weather-plugin" } }],
skillResults: [],
pluginResults: [{ type: "plugin", plugin: { name: "weather-plugin" } }],
skillCount: 0,
pluginCount: 1,
skillHasMore: false,
pluginHasMore: false,
isSearching: false,
});
const route = await loadRoute();
const Component = route.__config.component as ComponentType;
render(<Component />);
fireEvent.click(screen.getByRole("button", { name: "Search all types" }));
expect(navigateMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/search",
replace: true,
search: expect.objectContaining({ type: undefined }),
}),
);
});
it("passes only result limits to unified search", async () => {
+29 -9
View File
@@ -110,9 +110,7 @@ export default function Header() {
const showTypeahead = !isSoulMode && typeaheadOpen && trimmedNavSearchQuery.length > 0;
const {
skillResults,
skillCount,
pluginResults,
pluginCount,
isSearching: typeaheadSearching,
} = useUnifiedSearch(navSearchQuery, "all", {
debounceMs: 180,
@@ -125,7 +123,7 @@ export default function Header() {
for (const result of skillResults) {
items.push({ kind: "skill", key: `skill-${result.skill._id}`, result });
}
if (skillCount > 0) {
if (skillResults.length > 0) {
items.push({
kind: "footer",
key: "footer-skills",
@@ -136,7 +134,7 @@ export default function Header() {
for (const result of pluginResults) {
items.push({ kind: "plugin", key: `plugin-${result.plugin.name}`, result });
}
if (pluginCount > 0) {
if (pluginResults.length > 0) {
items.push({
kind: "footer",
key: "footer-plugins",
@@ -145,12 +143,20 @@ export default function Header() {
});
}
return items;
}, [pluginCount, pluginResults, showTypeahead, skillCount, skillResults, trimmedNavSearchQuery]);
}, [pluginResults, showTypeahead, skillResults, trimmedNavSearchQuery]);
const activeTypeaheadItem = showTypeahead ? typeaheadItems[typeaheadActiveIndex] : undefined;
const activeTypeaheadId = activeTypeaheadItem
? getTypeaheadOptionId(activeTypeaheadItem)
: undefined;
useEffect(() => {
setTypeaheadActiveIndex(0);
}, [trimmedNavSearchQuery]);
useEffect(() => {
setTypeaheadActiveIndex((index) => Math.min(index, Math.max(typeaheadItems.length - 1, 0)));
}, [typeaheadItems.length]);
useEffect(() => {
if (!typeaheadOpen) return () => {};
const handlePointerDown = (event: PointerEvent) => {
@@ -370,6 +376,7 @@ export default function Header() {
<input
className="navbar-search-input"
type="search"
role="combobox"
placeholder={isSoulMode ? "Search souls..." : "Search skills and plugins"}
value={navSearchQuery}
onChange={(e) => {
@@ -379,8 +386,10 @@ export default function Header() {
onFocus={() => setTypeaheadOpen(true)}
onKeyDown={handleSearchKeyDown}
aria-label="Search"
aria-autocomplete="list"
aria-expanded={showTypeahead}
aria-controls="navbar-search-typeahead"
aria-activedescendant={activeTypeaheadId}
autoComplete="off"
/>
</form>
@@ -630,7 +639,12 @@ function SearchTypeahead({
const hasMatches = skillItems.length > 0 || pluginItems.length > 0;
return (
<div className="navbar-search-typeahead" id="navbar-search-typeahead" role="listbox">
<div
className="navbar-search-typeahead"
id="navbar-search-typeahead"
role="listbox"
aria-label="Search suggestions"
>
<TypeaheadSection
activeIndex={activeIndex}
items={items}
@@ -721,6 +735,7 @@ function TypeaheadRow({
const body = getTypeaheadRowBody(item);
return (
<button
id={getTypeaheadOptionId(item)}
className={`navbar-search-typeahead-row${active ? " is-active" : ""}${item.kind === "footer" ? " is-footer" : ""}`}
type="button"
role="option"
@@ -739,6 +754,10 @@ function TypeaheadRow({
);
}
function getTypeaheadOptionId(item: TypeaheadItem) {
return `navbar-search-typeahead-${item.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
function getTypeaheadRowBody(item: TypeaheadItem) {
if (item.kind === "skill") {
const owner = item.result.ownerHandle ? `@${item.result.ownerHandle}` : "Skill";
@@ -749,12 +768,13 @@ function getTypeaheadRowBody(item: TypeaheadItem) {
};
}
if (item.kind === "plugin") {
const owner = item.result.plugin.ownerHandle
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
: item.result.plugin.name;
return {
icon: "P",
title: item.result.plugin.displayName,
meta: item.result.plugin.ownerHandle
? `@${item.result.plugin.ownerHandle} / ${item.result.plugin.name}`
: item.result.plugin.name,
meta: owner,
};
}
return {
+30
View File
@@ -539,6 +539,36 @@ describe("fetchPluginCatalog", () => {
expect(url.searchParams.has("sort")).toBe(false);
});
it("ignores malformed plugin search entries defensively", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
results: [
null,
{
score: 4,
package: {
name: "bundle-demo",
displayName: "Bundle Demo",
family: "bundle-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
},
},
],
}),
{ status: 200 },
),
);
const result = await fetchPluginCatalog({ q: "demo" });
expect(result.items.map((item) => item.name)).toEqual(["bundle-demo"]);
});
it("keeps relevance as the implicit plugins search sort", async () => {
vi.stubEnv("VITE_CONVEX_URL", "https://registry.example");
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+12 -9
View File
@@ -353,10 +353,12 @@ export async function fetchPackages(params: {
}
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
if (params.category) url.searchParams.set("category", params.category);
return await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(
url,
params.signal,
);
return await fetchJson<{
results: Array<{
score: number;
package: PackageListItem;
}>;
}>(url, params.signal);
}
const route =
@@ -409,7 +411,7 @@ export async function fetchPluginCatalog(params: {
});
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
return {
items: response.results.map((entry) => entry?.package).filter(Boolean) as PackageListItem[],
items: response.results.map((entry) => entry?.package).filter(Boolean),
nextCursor: null,
};
}
@@ -434,12 +436,13 @@ export async function fetchPluginCatalog(params: {
}
if (params.category) url.searchParams.set("category", params.category);
const response = await fetchJson<{
results?: Array<{ score: number; package: PackageListItem }>;
results?: Array<{
score: number;
package: PackageListItem;
}>;
}>(url, params.signal);
return {
items: (response?.results ?? [])
.map((entry) => entry?.package)
.filter(Boolean) as PackageListItem[],
items: (response?.results ?? []).map((entry) => entry?.package).filter(Boolean),
nextCursor: null,
};
}
+113
View File
@@ -0,0 +1,113 @@
/* @vitest-environment jsdom */
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useUnifiedSearch } from "./useUnifiedSearch";
const { searchSkillsMock, fetchPluginCatalogMock } = vi.hoisted(() => ({
searchSkillsMock: vi.fn(),
fetchPluginCatalogMock: vi.fn(),
}));
vi.mock("convex/react", () => ({
useAction: () => searchSkillsMock,
}));
vi.mock("./packageApi", () => ({
fetchPluginCatalog: (...args: unknown[]) => fetchPluginCatalogMock(...args),
}));
function makeSkill(slug: string) {
return {
skill: {
_id: `skills:${slug}`,
slug,
displayName: slug,
ownerUserId: "users:owner",
stats: { downloads: 0, stars: 0 },
updatedAt: 1,
createdAt: 1,
},
ownerHandle: "owner",
score: 1,
};
}
function makePlugin(name: string) {
return {
name,
displayName: name,
family: "code-plugin",
channel: "community",
isOfficial: false,
createdAt: 1,
updatedAt: 1,
};
}
describe("useUnifiedSearch", () => {
beforeEach(() => {
searchSkillsMock.mockReset();
fetchPluginCatalogMock.mockReset();
});
it("requests one extra result and exposes hasMore without inflating counts", async () => {
searchSkillsMock.mockResolvedValue([makeSkill("one"), makeSkill("two"), makeSkill("three")]);
fetchPluginCatalogMock.mockResolvedValue({
items: [makePlugin("one-plugin"), makePlugin("two-plugin"), makePlugin("three-plugin")],
nextCursor: null,
});
const { result } = renderHook(() =>
useUnifiedSearch("ghost", "all", {
debounceMs: 0,
limits: { skills: 2, plugins: 2 },
}),
);
await waitFor(() => {
expect(result.current.skillCount).toBe(2);
expect(result.current.pluginCount).toBe(2);
});
expect(searchSkillsMock).toHaveBeenCalledWith({
query: "ghost",
limit: 3,
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({ q: "ghost", limit: 3 }),
);
expect(result.current.skillResults.map((entry) => entry.skill.slug)).toEqual(["one", "two"]);
expect(result.current.pluginResults.map((entry) => entry.plugin.name)).toEqual([
"one-plugin",
"two-plugin",
]);
expect(result.current.skillHasMore).toBe(true);
expect(result.current.pluginHasMore).toBe(true);
});
it("caps requested limits at the backend search maximum", async () => {
searchSkillsMock.mockResolvedValue([]);
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
renderHook(() =>
useUnifiedSearch("ghost", "all", {
debounceMs: 0,
limits: { skills: 150, plugins: 150 },
}),
);
await waitFor(() => {
expect(searchSkillsMock).toHaveBeenCalled();
expect(fetchPluginCatalogMock).toHaveBeenCalled();
});
expect(searchSkillsMock).toHaveBeenCalledWith({
query: "ghost",
limit: 101,
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({ q: "ghost", limit: 101 }),
);
});
});
+30 -7
View File
@@ -4,6 +4,7 @@ import { api } from "../../convex/_generated/api";
import { fetchPluginCatalog, type PackageListItem } from "./packageApi";
export type UnifiedSearchType = "all" | "skills" | "plugins";
const MAX_UNIFIED_SEARCH_LIMIT = 100;
export type UnifiedSkillResult = {
type: "skill";
@@ -49,12 +50,17 @@ export function useUnifiedSearch(
const [pluginResults, setPluginResults] = useState<UnifiedPluginResult[]>([]);
const [skillCount, setSkillCount] = useState(0);
const [pluginCount, setPluginCount] = useState(0);
const [skillHasMore, setSkillHasMore] = useState(false);
const [pluginHasMore, setPluginHasMore] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const requestRef = useRef(0);
const debounceMs = options.debounceMs ?? 300;
const enabled = options.enabled ?? true;
const skillLimit = options.limits?.skills ?? 25;
const pluginLimit = options.limits?.plugins ?? 25;
const skillLimit = Math.max(0, Math.min(options.limits?.skills ?? 25, MAX_UNIFIED_SEARCH_LIMIT));
const pluginLimit = Math.max(
0,
Math.min(options.limits?.plugins ?? 25, MAX_UNIFIED_SEARCH_LIMIT),
);
useEffect(() => {
const trimmed = query.trim();
@@ -65,6 +71,8 @@ export function useUnifiedSearch(
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setSkillHasMore(false);
setPluginHasMore(false);
setIsSearching(false);
return () => {};
}
@@ -83,14 +91,14 @@ export function useUnifiedSearch(
if (activeType === "all" || activeType === "skills") {
promises[0] = searchSkills({
query: trimmed,
limit: skillLimit,
limit: skillLimit + 1,
});
}
if (activeType === "all" || activeType === "plugins") {
promises[1] = fetchPluginCatalog({
q: trimmed,
limit: pluginLimit,
limit: pluginLimit + 1,
signal: controller.signal,
});
}
@@ -102,7 +110,7 @@ export function useUnifiedSearch(
const skillsRaw = settled[0].status === "fulfilled" ? settled[0].value : null;
const pluginsRaw = settled[1].status === "fulfilled" ? settled[1].value : null;
const nextSkillResults: UnifiedSkillResult[] = (
const skillMatches: UnifiedSkillResult[] = (
(skillsRaw as Array<{
skill: UnifiedSkillResult["skill"];
ownerHandle: string | null;
@@ -114,16 +122,20 @@ export function useUnifiedSearch(
ownerHandle: entry.ownerHandle,
score: entry.score,
}));
const nextSkillResults = skillMatches.slice(0, skillLimit);
const nextPluginResults: UnifiedPluginResult[] = (
const pluginMatches: UnifiedPluginResult[] = (
(pluginsRaw as { items: PackageListItem[] })?.items ?? []
).map((item) => ({
type: "plugin" as const,
plugin: item,
}));
const nextPluginResults = pluginMatches.slice(0, pluginLimit);
setSkillCount(nextSkillResults.length);
setPluginCount(nextPluginResults.length);
setSkillHasMore(skillMatches.length > skillLimit);
setPluginHasMore(pluginMatches.length > pluginLimit);
setSkillResults(nextSkillResults);
setPluginResults(nextPluginResults);
@@ -145,6 +157,8 @@ export function useUnifiedSearch(
setPluginResults([]);
setSkillCount(0);
setPluginCount(0);
setSkillHasMore(false);
setPluginHasMore(false);
}
} finally {
if (requestId === requestRef.current) {
@@ -161,5 +175,14 @@ export function useUnifiedSearch(
};
}, [query, activeType, searchSkills, debounceMs, enabled, skillLimit, pluginLimit]);
return { results, skillResults, pluginResults, skillCount, pluginCount, isSearching };
return {
results,
skillResults,
pluginResults,
skillCount,
pluginCount,
skillHasMore,
pluginHasMore,
isSearching,
};
}
+119 -22
View File
@@ -44,8 +44,12 @@ function UnifiedSearchPage() {
const {
results: allResults,
skillResults,
pluginResults,
skillCount,
pluginCount,
skillHasMore,
pluginHasMore,
isSearching,
} = useUnifiedSearch(search.q ?? "", "all", {
limits: {
@@ -53,18 +57,18 @@ function UnifiedSearchPage() {
plugins: resultLimit,
},
});
const results =
activeType === "all"
? allResults
: allResults.filter((item) => item.type === (activeType === "skills" ? "skill" : "plugin"));
const results: Array<UnifiedSkillResult | UnifiedPluginResult> =
activeType === "all" ? allResults : activeType === "skills" ? skillResults : pluginResults;
const showSearchCounts = Boolean(search.q);
const allCount = skillCount + pluginCount;
const allHasMore = skillHasMore || pluginHasMore;
const canLoadMore =
search.q &&
!isSearching &&
((activeType === "all" && (skillCount >= resultLimit || pluginCount >= resultLimit)) ||
(activeType === "skills" && skillCount >= resultLimit) ||
(activeType === "plugins" && pluginCount >= resultLimit));
((activeType === "all" && allHasMore) ||
(activeType === "skills" && skillHasMore) ||
(activeType === "plugins" && pluginHasMore));
const hasOtherTypeMatches = activeType !== "all" && allCount > 0;
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
@@ -139,14 +143,20 @@ function UnifiedSearchPage() {
type="button"
onClick={() => setType("all")}
>
All {showSearchCounts ? <span className="search-tab-count">{allCount}</span> : null}
All{" "}
{showSearchCounts ? (
<span className="search-tab-count">{formatSearchCount(allCount, allHasMore)}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "skills" ? " is-active" : ""}`}
type="button"
onClick={() => setType("skills")}
>
Skills {showSearchCounts ? <span className="search-tab-count">{skillCount}</span> : null}
Skills{" "}
{showSearchCounts ? (
<span className="search-tab-count">{formatSearchCount(skillCount, skillHasMore)}</span>
) : null}
</button>
<button
className={`search-tab${activeType === "plugins" ? " is-active" : ""}`}
@@ -154,7 +164,11 @@ function UnifiedSearchPage() {
onClick={() => setType("plugins")}
>
Plugins{" "}
{showSearchCounts ? <span className="search-tab-count">{pluginCount}</span> : null}
{showSearchCounts ? (
<span className="search-tab-count">
{formatSearchCount(pluginCount, pluginHasMore)}
</span>
) : null}
</button>
</div>
@@ -167,20 +181,48 @@ function UnifiedSearchPage() {
<p className="text-ink-soft">Enter a search term to find skills and plugins</p>
</Card>
) : results.length === 0 ? (
<Card className="text-center p-10">
<p className="text-ink-soft">No results found for "{search.q}"</p>
</Card>
<SearchEmptyState
activeType={activeType}
hasOtherTypeMatches={hasOtherTypeMatches}
onSearchAllTypes={() => setType("all")}
query={search.q}
/>
) : (
<>
<div className="results-list">
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
{activeType === "all" ? (
<div className="search-results-sections">
{skillResults.length > 0 ? (
<SearchResultSection
countLabel={formatSearchCount(skillCount, skillHasMore)}
title="Skills"
>
{skillResults.map((item) => (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
))}
</SearchResultSection>
) : null}
{pluginResults.length > 0 ? (
<SearchResultSection
countLabel={formatSearchCount(pluginCount, pluginHasMore)}
title="Plugins"
>
{pluginResults.map((item) => (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
))}
</SearchResultSection>
) : null}
</div>
) : (
<div className="results-list">
{results.map((item) =>
item.type === "skill" ? (
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
) : (
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
),
)}
</div>
)}
{canLoadMore ? (
<div className="search-load-more">
<button
@@ -198,6 +240,61 @@ function UnifiedSearchPage() {
);
}
function SearchEmptyState({
activeType,
hasOtherTypeMatches,
onSearchAllTypes,
query,
}: {
activeType: UnifiedSearchType;
hasOtherTypeMatches: boolean;
onSearchAllTypes: () => void;
query: string;
}) {
const browseHref = activeType === "plugins" ? "/plugins" : "/skills";
const browseLabel = activeType === "plugins" ? "Show all plugins" : "Show all skills";
return (
<Card className="search-empty-state">
<p className="search-empty-title">No matches for "{query}"</p>
<div className="search-empty-actions">
{hasOtherTypeMatches ? (
<button type="button" className="search-empty-action" onClick={onSearchAllTypes}>
Search all types
</button>
) : null}
<a className="search-empty-action" href={browseHref}>
{browseLabel}
</a>
</div>
</Card>
);
}
function formatSearchCount(count: number, hasMore: boolean) {
return hasMore ? `${count}+` : String(count);
}
function SearchResultSection({
children,
countLabel,
title,
}: {
children: React.ReactNode;
countLabel: string;
title: string;
}) {
return (
<section className="search-results-section" aria-label={title}>
<div className="search-results-section-header">
<h2 className="search-results-section-title">{title}</h2>
<span className="search-results-section-count">{countLabel}</span>
</div>
<div className="results-list">{children}</div>
</section>
);
}
function SkillResultRow({ result }: { result: UnifiedSkillResult }) {
const skill = result.skill as unknown as PublicSkill;
return <SkillListItem skill={skill} ownerHandle={result.ownerHandle} />;
+72
View File
@@ -10578,6 +10578,78 @@ a.agentic-risk-finding-title:focus-visible {
color: var(--ink-soft);
}
.search-results-sections {
display: grid;
gap: var(--space-5);
}
.search-results-section {
display: grid;
gap: var(--space-3);
}
.search-results-section-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.search-results-section-title {
margin: 0;
font-size: var(--fs-sm);
font-weight: 700;
color: var(--ink);
}
.search-results-section-count {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--ink-soft);
}
.search-empty-state {
display: grid;
justify-items: center;
gap: var(--space-3);
padding: var(--space-8);
text-align: center;
}
.search-empty-title {
margin: 0;
color: var(--ink-soft);
}
.search-empty-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-2);
}
.search-empty-action {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
background: var(--surface);
color: var(--ink);
cursor: pointer;
font-size: var(--fs-xs);
font-weight: 700;
padding: 0 var(--space-3);
}
.search-empty-action:hover {
border-color: color-mix(in srgb, var(--ink) 18%, var(--line));
background: var(--surface-raised);
}
a.search-empty-action {
display: inline-flex;
align-items: center;
text-decoration: none;
}
.search-load-more {
display: flex;
justify-content: center;