mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
1798 lines
65 KiB
TypeScript
1798 lines
65 KiB
TypeScript
import {
|
|
getCatalogTopicSlugs,
|
|
INTERNAL_UNCATEGORIZED_CATEGORY,
|
|
isSkillCategorySlug,
|
|
normalizeCatalogTopic,
|
|
resolveStoredSkillCategories,
|
|
type SkillCategorySlug,
|
|
} from "clawhub-schema";
|
|
import { v } from "convex/values";
|
|
import { internal } from "./_generated/api";
|
|
import type { Doc, Id } from "./_generated/dataModel";
|
|
import type { ActionCtx, QueryCtx } from "./_generated/server";
|
|
import { action, internalQuery } from "./functions";
|
|
import { isSkillHighlighted, isSkillOfficial } from "./lib/badges";
|
|
import {
|
|
classifyCanonicalSkillSearchMatch,
|
|
compareCanonicalSkillSearchCandidates,
|
|
type CanonicalSkillSearchCandidate,
|
|
} from "./lib/canonicalSkillSearch";
|
|
import { CANONICAL_SKILL_SEARCH_BOUNDS } from "./lib/canonicalSkillSearchBounds";
|
|
import { generateEmbedding } from "./lib/embeddings";
|
|
import { toDayKey } from "./lib/leaderboards";
|
|
import { hasOfficialPublisherRow, toPublicPublisherWithOfficial } from "./lib/officialPublishers";
|
|
import type { HydratableSkill, PublicPublisher } from "./lib/public";
|
|
import { toPublicSkill } from "./lib/public";
|
|
import {
|
|
hasResolvablePublicBrowseVersionFromState,
|
|
shouldExcludeSkillFromPublicBrowse,
|
|
} from "./lib/publicBrowse";
|
|
import {
|
|
getActiveUserByHandleOrPersonalPublisher,
|
|
getOwnerPublisher,
|
|
getPublisherByHandle,
|
|
} from "./lib/publishers";
|
|
import {
|
|
matchesAllTokens,
|
|
matchesExactTokens,
|
|
matchesExploratoryTokenPrefixes,
|
|
tokenize,
|
|
} from "./lib/searchText";
|
|
import { isSkillSuspicious } from "./lib/skillSafety";
|
|
import {
|
|
digestToHydratableSkill,
|
|
digestToOwnerInfo,
|
|
getFirstSearchToken,
|
|
normalizeSkillSearchText,
|
|
} from "./lib/skillSearchDigest";
|
|
import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator";
|
|
import { getSkillsShPublicCatalogEnabledHandler } from "./rolloutCapabilities";
|
|
|
|
type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null };
|
|
|
|
function makeOwnerInfoGetter(ctx: Pick<QueryCtx, "db">) {
|
|
const ownerCache = new Map<string, Promise<OwnerInfo>>();
|
|
return (ownerUserId: Id<"users">, ownerPublisherId?: Id<"publishers"> | null) => {
|
|
const cacheKey = String(ownerPublisherId ?? ownerUserId);
|
|
const cached = ownerCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
const ownerPromise = getOwnerPublisher(ctx, {
|
|
ownerPublisherId,
|
|
ownerUserId,
|
|
}).then(async (ownerDoc) => {
|
|
const owner = await toPublicPublisherWithOfficial(ctx, ownerDoc);
|
|
return {
|
|
ownerHandle: owner?.handle ?? null,
|
|
owner,
|
|
};
|
|
});
|
|
ownerCache.set(cacheKey, ownerPromise);
|
|
return ownerPromise;
|
|
};
|
|
}
|
|
|
|
async function withOfficialOwnerInfo(ctx: Pick<QueryCtx, "db">, ownerInfo: OwnerInfo) {
|
|
if (!ownerInfo.owner) return ownerInfo;
|
|
if (ownerInfo.owner.official) return ownerInfo;
|
|
const official = await hasOfficialPublisherRow(ctx, ownerInfo.owner._id);
|
|
if (!official) return ownerInfo;
|
|
return {
|
|
...ownerInfo,
|
|
owner: {
|
|
...ownerInfo.owner,
|
|
official: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
type SkillSearchEntry = {
|
|
embeddingId?: Id<"skillEmbeddings">;
|
|
skill: NonNullable<ReturnType<typeof toPublicSkill>>;
|
|
version: Doc<"skillVersions"> | null;
|
|
ownerHandle: string | null;
|
|
owner: PublicPublisher | null;
|
|
};
|
|
|
|
type SearchMatch = {
|
|
rankTier: number;
|
|
};
|
|
|
|
type SearchResult = SkillSearchEntry &
|
|
SearchMatch & {
|
|
score: number;
|
|
semanticScore: number;
|
|
candidateRelevance: CanonicalSkillSearchCandidate["relevance"];
|
|
};
|
|
type PublicSearchResult = SkillSearchEntry & {
|
|
score: number;
|
|
semanticScore: number;
|
|
};
|
|
|
|
const EXACT_SLUG_BOOST = 2.5;
|
|
const SLUG_TOKEN_BOOST = 1.4;
|
|
const SLUG_PREFIX_BOOST = 0.8;
|
|
const NAME_EXACT_BOOST = 1.1;
|
|
const NAME_PREFIX_BOOST = 0.6;
|
|
const FALLBACK_SCAN_LIMIT = 2000;
|
|
const MIN_FALLBACK_SCAN_LIMIT = 100;
|
|
const FALLBACK_RECALL_MULTIPLIER = 2;
|
|
const MIN_STABLE_SEARCH_RECALL_LIMIT = 100;
|
|
const MAX_DIRECT_SKILL_SEARCH_CANDIDATES = 100;
|
|
const MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES = 40;
|
|
const MAX_DIRECT_SKILL_TOPIC_CANDIDATES = 100;
|
|
// Scoped direct recall fans out across up to nine indexed reads in one query.
|
|
// Keep each source small enough that the aggregate stays below Convex read limits.
|
|
const MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES = 250;
|
|
const MIN_VECTOR_SEARCH_CANDIDATES = 50;
|
|
const MAX_VECTOR_SEARCH_CANDIDATES = CANONICAL_SKILL_SEARCH_BOUNDS.vectorCandidateLimit;
|
|
const MAX_EXACT_SLUG_MATCHES = 25;
|
|
const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3;
|
|
|
|
function getNextCandidateLimit(current: number, max: number) {
|
|
const next = Math.min(current * 2, max);
|
|
return next > current ? next : null;
|
|
}
|
|
|
|
function getLexicalBoost(queryTokens: string[], displayName: string, slug: string) {
|
|
const slugTokens = tokenize(slug);
|
|
const nameTokens = tokenize(displayName);
|
|
|
|
let boost = 0;
|
|
const normalizedQuery = queryTokens.join("-");
|
|
if (normalizedQuery === slug) {
|
|
boost += EXACT_SLUG_BOOST;
|
|
} else if (matchesAllTokens(queryTokens, slugTokens, (candidate, query) => candidate === query)) {
|
|
boost += SLUG_TOKEN_BOOST;
|
|
} else if (
|
|
matchesAllTokens(queryTokens, slugTokens, (candidate, query) => candidate.startsWith(query))
|
|
) {
|
|
boost += SLUG_PREFIX_BOOST;
|
|
}
|
|
|
|
if (matchesAllTokens(queryTokens, nameTokens, (candidate, query) => candidate === query)) {
|
|
boost += NAME_EXACT_BOOST;
|
|
} else if (
|
|
matchesAllTokens(queryTokens, nameTokens, (candidate, query) => candidate.startsWith(query))
|
|
) {
|
|
boost += NAME_PREFIX_BOOST;
|
|
}
|
|
|
|
return boost;
|
|
}
|
|
|
|
function scoreSkillResult(
|
|
queryTokens: string[],
|
|
vectorScore: number,
|
|
displayName: string,
|
|
slug: string,
|
|
) {
|
|
const lexicalBoost = getLexicalBoost(queryTokens, displayName, slug);
|
|
return vectorScore + lexicalBoost;
|
|
}
|
|
|
|
function classifySkillMatch(
|
|
query: string,
|
|
queryTokens: string[],
|
|
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "categories" | "topics">,
|
|
semanticScore = 0,
|
|
): 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 };
|
|
}
|
|
const taxonomyQuery = normalizeCatalogTopic(query);
|
|
const categories = (skill.categories ?? []).filter(
|
|
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
|
|
);
|
|
const topicSlugs = getCatalogTopicSlugs(skill.topics);
|
|
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
|
|
return { rankTier: 2 };
|
|
}
|
|
if (
|
|
matchesExploratoryTokenPrefixes(
|
|
queryTokens,
|
|
[...categories, ...(skill.topics ?? [])],
|
|
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
|
)
|
|
) {
|
|
return { rankTier: 2 };
|
|
}
|
|
if (
|
|
matchesExploratoryTokenPrefixes(
|
|
queryTokens,
|
|
[skill.summary],
|
|
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
|
)
|
|
) {
|
|
return { rankTier: 3 };
|
|
}
|
|
if (semanticScore >= 0.55) {
|
|
return { rankTier: 4 };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function compareSkillTrust(a: SkillSearchEntry, b: SkillSearchEntry) {
|
|
return Number(Boolean(b.owner?.official)) - Number(Boolean(a.owner?.official));
|
|
}
|
|
|
|
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
|
|
if (fallback.length === 0) return primary;
|
|
const out = [...primary];
|
|
const seen = new Set(primary.map((entry) => entry.skill._id));
|
|
for (const entry of fallback) {
|
|
if (seen.has(entry.skill._id)) continue;
|
|
seen.add(entry.skill._id);
|
|
out.push(entry);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function matchesCatalogTopic(skill: Pick<HydratableSkill, "topics">, topic: string | undefined) {
|
|
return !topic || getCatalogTopicSlugs(skill.topics).includes(topic);
|
|
}
|
|
|
|
function normalizeSkillCategoryFilter(categorySlug: string | undefined) {
|
|
if (categorySlug === undefined) return undefined;
|
|
const normalized = categorySlug.trim().toLowerCase();
|
|
return isSkillCategorySlug(normalized) ? normalized : null;
|
|
}
|
|
|
|
function matchesCatalogFilters(
|
|
skill: Parameters<typeof resolveStoredSkillCategories>[0] & Pick<HydratableSkill, "topics">,
|
|
categorySlug: SkillCategorySlug | undefined,
|
|
topic: string | undefined,
|
|
) {
|
|
return (
|
|
(!categorySlug || resolveStoredSkillCategories(skill).includes(categorySlug)) &&
|
|
matchesCatalogTopic(skill, topic)
|
|
);
|
|
}
|
|
|
|
function matchesNativeSearchEligibility(
|
|
skill: Pick<HydratableSkill, "badges" | "createdAt">,
|
|
args: Pick<SkillSearchArgs, "officialOnly" | "createdAfter">,
|
|
) {
|
|
return (
|
|
(!args.officialOnly || isSkillOfficial(skill)) &&
|
|
(args.createdAfter === undefined || skill.createdAt >= args.createdAfter)
|
|
);
|
|
}
|
|
|
|
function toPublicSearchSkill(skill: HydratableSkill) {
|
|
if (shouldExcludeSkillFromPublicBrowse(skill)) return null;
|
|
return toPublicSkill({
|
|
...skill,
|
|
categories: resolveStoredSkillCategories(skill),
|
|
});
|
|
}
|
|
|
|
type SkillDigestCandidateQuery = {
|
|
take: (limit: number) => Promise<Doc<"skillSearchDigest">[]>;
|
|
};
|
|
type SkillDigestCandidateQueryFactory = () => SkillDigestCandidateQuery;
|
|
|
|
async function collectFilteredSkillDigestCandidates(
|
|
createQuery: SkillDigestCandidateQueryFactory,
|
|
opts: {
|
|
limit: number;
|
|
scanLimit: number;
|
|
matches: (digest: Doc<"skillSearchDigest">) => boolean;
|
|
},
|
|
) {
|
|
// Convex permits only one paginated read per query function. Use one bounded
|
|
// take so the several recall indexes can be searched in the same transaction.
|
|
const candidates = await createQuery().take(opts.scanLimit);
|
|
return candidates.filter(opts.matches).slice(0, opts.limit);
|
|
}
|
|
|
|
function isSlugLikeQuery(query: string) {
|
|
// Lenient shape check used by the read path: pattern + upper length cap only.
|
|
// The min-length floor and reserved-word blocklist are intentionally omitted
|
|
// so legacy rows (grandfathered short/reserved slugs) remain discoverable via
|
|
// the exact-slug fast path. Write paths still go through assertValidSkillSlug.
|
|
return isSearchableSkillSlugShape(query);
|
|
}
|
|
|
|
function prefixUpperBound(value: string) {
|
|
return `${value}\uffff`;
|
|
}
|
|
|
|
const skillSearchArgs = {
|
|
query: v.string(),
|
|
limit: v.optional(v.number()),
|
|
mode: v.optional(v.literal("exact")),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
nonSuspiciousOnly: v.optional(v.boolean()),
|
|
excludePendingScan: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
};
|
|
|
|
const nativeSkillSearchArgs = {
|
|
...skillSearchArgs,
|
|
officialOnly: v.optional(v.boolean()),
|
|
createdAfter: v.optional(v.number()),
|
|
};
|
|
|
|
type SkillSearchArgs = {
|
|
query: string;
|
|
limit?: number;
|
|
mode?: "exact";
|
|
highlightedOnly?: boolean;
|
|
nonSuspiciousOnly?: boolean;
|
|
excludePendingScan?: boolean;
|
|
categorySlug?: string;
|
|
topic?: string;
|
|
officialOnly?: boolean;
|
|
createdAfter?: number;
|
|
};
|
|
|
|
const nativeSkillSearch = {
|
|
async handler(ctx: ActionCtx, args: SkillSearchArgs): Promise<PublicSearchResult[]> {
|
|
const query = args.query.trim();
|
|
if (!query) return [];
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
const queryTokens = tokenize(query);
|
|
if (queryTokens.length === 0) return [];
|
|
if (args.mode === "exact") {
|
|
const exactLimit = Math.min(
|
|
Math.max(Math.trunc(args.limit ?? 10), 1),
|
|
MAX_EXACT_SLUG_MATCHES,
|
|
);
|
|
const exactSlugMatches = isSlugLikeQuery(query.toLowerCase())
|
|
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
|
slug: query.toLowerCase(),
|
|
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
|
categorySlug,
|
|
topic,
|
|
officialOnly: args.officialOnly,
|
|
createdAfter: args.createdAfter,
|
|
})) as SkillSearchEntry[])
|
|
: [];
|
|
return exactSlugMatches
|
|
.filter(
|
|
(entry) =>
|
|
(!args.highlightedOnly || isSkillHighlighted(entry.skill)) &&
|
|
(!args.excludePendingScan || entry.skill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(entry.skill, args),
|
|
)
|
|
.sort(compareSkillTrust)
|
|
.slice(0, exactLimit)
|
|
.map((entry) => ({
|
|
...entry,
|
|
score: EXACT_SLUG_BOOST,
|
|
semanticScore: 0,
|
|
}));
|
|
}
|
|
const rawExactSlugMatches = isSlugLikeQuery(query)
|
|
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
|
slug: query.toLowerCase(),
|
|
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
|
categorySlug,
|
|
topic,
|
|
officialOnly: args.officialOnly,
|
|
createdAfter: args.createdAfter,
|
|
})) as SkillSearchEntry[] | SkillSearchEntry | null)
|
|
: [];
|
|
const exactSlugMatches = (
|
|
Array.isArray(rawExactSlugMatches)
|
|
? rawExactSlugMatches
|
|
: rawExactSlugMatches
|
|
? [rawExactSlugMatches]
|
|
: []
|
|
).filter(
|
|
(entry) =>
|
|
(!args.highlightedOnly || isSkillHighlighted(entry.skill)) &&
|
|
(!args.excludePendingScan || entry.skill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(entry.skill, args),
|
|
);
|
|
const directPrefixMatches = (
|
|
(await ctx.runQuery(internal.search.directPrefixSkillMatches, {
|
|
query,
|
|
highlightedOnly: args.highlightedOnly,
|
|
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
|
categorySlug,
|
|
topic,
|
|
officialOnly: args.officialOnly,
|
|
createdAfter: args.createdAfter,
|
|
})) as SkillSearchEntry[]
|
|
).filter(
|
|
(entry) =>
|
|
(!args.excludePendingScan || entry.skill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(entry.skill, args),
|
|
);
|
|
let vector: number[] | null;
|
|
try {
|
|
vector = await generateEmbedding(query);
|
|
} catch (error) {
|
|
console.warn("Search embedding generation failed, falling back to lexical search", error);
|
|
vector = null;
|
|
}
|
|
const limit = args.limit ?? 10;
|
|
// Keep ordinary first-page and load-more requests ranking the same recall pool
|
|
// before slicing, so expanding the display limit does not reshuffle the prefix.
|
|
const recallLimit = Math.max(limit, MIN_STABLE_SEARCH_RECALL_LIMIT);
|
|
// Keep the vector pool bounded; exact slug, prefix, and lexical fallback cover
|
|
// literal recall without hydrating hundreds of semantic candidates per search.
|
|
const maxCandidate = Math.min(
|
|
Math.max(limit * 4, MIN_VECTOR_SEARCH_CANDIDATES),
|
|
MAX_VECTOR_SEARCH_CANDIDATES,
|
|
);
|
|
let candidateLimit = Math.min(Math.max(limit * 2, MIN_VECTOR_SEARCH_CANDIDATES), maxCandidate);
|
|
let hydrated: SkillSearchEntry[] = [];
|
|
const seenEmbeddingIds = new Set<Id<"skillEmbeddings">>();
|
|
let scoreById = new Map<Id<"skillEmbeddings">, number>();
|
|
const scoreBySkillId = new Map<Id<"skills">, number>();
|
|
let exactMatches: SkillSearchEntry[] = [];
|
|
|
|
if (vector) {
|
|
while (candidateLimit <= maxCandidate) {
|
|
const results = await ctx.vectorSearch("skillEmbeddings", "by_embedding", {
|
|
vector,
|
|
limit: candidateLimit,
|
|
filter: (q) => q.or(q.eq("visibility", "latest"), q.eq("visibility", "latest-approved")),
|
|
});
|
|
|
|
// Only hydrate embedding IDs we haven't seen yet (incremental).
|
|
// Track all attempted IDs, not just successful hydrations, to avoid
|
|
// re-hydrating filtered-out entries (soft-deleted, suspicious) each loop.
|
|
const newEmbeddingIds = results.map((r) => r._id).filter((id) => !seenEmbeddingIds.has(id));
|
|
for (const id of newEmbeddingIds) seenEmbeddingIds.add(id);
|
|
|
|
if (newEmbeddingIds.length > 0) {
|
|
const newEntries = (await ctx.runQuery(internal.search.hydrateResults, {
|
|
embeddingIds: newEmbeddingIds,
|
|
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
|
categorySlug,
|
|
topic,
|
|
officialOnly: args.officialOnly,
|
|
createdAfter: args.createdAfter,
|
|
})) as SkillSearchEntry[];
|
|
hydrated = [...hydrated, ...newEntries];
|
|
}
|
|
|
|
for (const result of results) {
|
|
scoreById.set(result._id, result._score);
|
|
}
|
|
|
|
for (const entry of hydrated) {
|
|
if (!entry.embeddingId) continue;
|
|
const score = scoreById.get(entry.embeddingId);
|
|
if (score !== undefined) scoreBySkillId.set(entry.skill._id, score);
|
|
}
|
|
|
|
// Skills already have badges from their docs (via toPublicSkill).
|
|
// No need for a separate badge table lookup.
|
|
const filtered = hydrated.filter(
|
|
(entry) =>
|
|
(!args.highlightedOnly || isSkillHighlighted(entry.skill)) &&
|
|
(!args.excludePendingScan || entry.skill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(entry.skill, args),
|
|
);
|
|
|
|
exactMatches = filtered.filter((entry) =>
|
|
matchesExactTokens(queryTokens, [
|
|
entry.skill.displayName,
|
|
entry.skill.slug,
|
|
entry.skill.summary,
|
|
...(entry.skill.categories ?? []),
|
|
...(entry.skill.topics ?? []),
|
|
]),
|
|
);
|
|
|
|
if (exactMatches.length >= recallLimit || results.length < candidateLimit) {
|
|
break;
|
|
}
|
|
|
|
const nextLimit = getNextCandidateLimit(candidateLimit, maxCandidate);
|
|
if (!nextLimit) break;
|
|
candidateLimit = nextLimit;
|
|
}
|
|
}
|
|
|
|
const directMatches =
|
|
exactSlugMatches.length > 0
|
|
? mergeUniqueBySkillId(exactSlugMatches, directPrefixMatches)
|
|
: directPrefixMatches;
|
|
const primaryMatches = mergeUniqueBySkillId(directMatches, exactMatches);
|
|
|
|
const fallbackMatches =
|
|
primaryMatches.length >= recallLimit
|
|
? []
|
|
: ((await ctx.runQuery(internal.search.lexicalFallbackSkills, {
|
|
query,
|
|
queryTokens,
|
|
limit: Math.min(
|
|
Math.max(recallLimit * FALLBACK_RECALL_MULTIPLIER, MIN_FALLBACK_SCAN_LIMIT),
|
|
FALLBACK_SCAN_LIMIT,
|
|
),
|
|
highlightedOnly: args.highlightedOnly,
|
|
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
|
excludePendingScan: args.excludePendingScan,
|
|
skipExactSlugLookup: true,
|
|
categorySlug,
|
|
topic,
|
|
officialOnly: args.officialOnly,
|
|
createdAfter: args.createdAfter,
|
|
})) as SkillSearchEntry[]);
|
|
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches).filter(
|
|
(entry) =>
|
|
matchesCatalogFilters(entry.skill, categorySlug, topic) &&
|
|
(!args.excludePendingScan || entry.skill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(entry.skill, args),
|
|
);
|
|
|
|
const rankedMatches = mergedMatches
|
|
.map((entry): SearchResult | null => {
|
|
const vectorScore = entry.embeddingId
|
|
? (scoreById.get(entry.embeddingId) ?? scoreBySkillId.get(entry.skill._id) ?? 0)
|
|
: (scoreBySkillId.get(entry.skill._id) ?? 0);
|
|
const match = classifySkillMatch(query, queryTokens, entry.skill, vectorScore);
|
|
if (!match) return null;
|
|
const candidateRelevance = classifyCanonicalSkillSearchMatch(query, {
|
|
identities: [entry.skill.slug],
|
|
name: entry.skill.displayName,
|
|
slug: entry.skill.slug,
|
|
taxonomy: [...(entry.skill.categories ?? []), ...(entry.skill.topics ?? [])],
|
|
summary: entry.skill.summary ?? null,
|
|
semanticScore: vectorScore,
|
|
});
|
|
if (!candidateRelevance) return null;
|
|
return {
|
|
...entry,
|
|
...match,
|
|
candidateRelevance,
|
|
semanticScore: vectorScore,
|
|
score: scoreSkillResult(
|
|
queryTokens,
|
|
vectorScore,
|
|
entry.skill.displayName,
|
|
entry.skill.slug,
|
|
),
|
|
};
|
|
})
|
|
.filter((entry): entry is SearchResult => Boolean(entry?.skill))
|
|
.sort(
|
|
(a, b) =>
|
|
a.candidateRelevance.tier - b.candidateRelevance.tier ||
|
|
b.candidateRelevance.lexicalScore - a.candidateRelevance.lexicalScore ||
|
|
b.candidateRelevance.semanticScore - a.candidateRelevance.semanticScore ||
|
|
compareSkillTrust(a, b) ||
|
|
b.skill.updatedAt - a.skill.updatedAt,
|
|
)
|
|
.slice(0, limit);
|
|
return rankedMatches.map(
|
|
({ rankTier: _rankTier, candidateRelevance: _candidateRelevance, ...entry }) => entry,
|
|
);
|
|
},
|
|
};
|
|
|
|
export const searchNativeSkills: ReturnType<typeof action> = action({
|
|
args: nativeSkillSearchArgs,
|
|
handler: async (ctx, args) => nativeSkillSearch.handler(ctx, args),
|
|
});
|
|
|
|
type RollingSkillUsage = {
|
|
skillId: Id<"skills">;
|
|
installs: number;
|
|
bookmarks: number;
|
|
};
|
|
|
|
type CanonicalSkillSearchResult = {
|
|
id: string;
|
|
source: "clawhub" | "skills-sh";
|
|
slug: string;
|
|
displayName: string;
|
|
summary: string | null;
|
|
icon: string | null;
|
|
score: number;
|
|
canonicalUrl: string;
|
|
links: {
|
|
canonical: string;
|
|
source: string | null;
|
|
};
|
|
publisher: {
|
|
kind: "user" | "org";
|
|
handle: string | null;
|
|
displayName: string | null;
|
|
image: string | null;
|
|
official: boolean;
|
|
} | null;
|
|
official: boolean;
|
|
featured: boolean;
|
|
install: {
|
|
kind: "clawhub" | "github" | "skills-sh";
|
|
reference: string;
|
|
sourceUrl: string | null;
|
|
};
|
|
sourceIdentity: {
|
|
id: string;
|
|
owner: string | null;
|
|
repo: string | null;
|
|
host: string | null;
|
|
lifetimeInstalls: number | null;
|
|
};
|
|
trust: {
|
|
visibility: "public";
|
|
installability: "installable";
|
|
clawHubVerdict: string | null;
|
|
upstreamScanners: Doc<"skillsShMirrorDigests">["upstreamScanners"] | null;
|
|
sourceFreshness: "native" | "observed-only";
|
|
};
|
|
metrics: {
|
|
rolling60DayInstalls: number | null;
|
|
bookmarks: number | null;
|
|
updatedAt: number;
|
|
};
|
|
// Native rendering payload. External rows intentionally omit this; CLAW-583
|
|
// owns their detail/install presentation rather than this ranking contract.
|
|
native: {
|
|
skill: PublicSearchResult["skill"];
|
|
version: PublicSearchResult["version"];
|
|
owner: PublicSearchResult["owner"];
|
|
ownerHandle: PublicSearchResult["ownerHandle"];
|
|
} | null;
|
|
// Compatibility fields retained for existing CLI/OpenClaw parsers.
|
|
ownerHandle: string | null;
|
|
version: string | null;
|
|
downloads: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
const CANONICAL_NATIVE_CANDIDATE_LIMIT = CANONICAL_SKILL_SEARCH_BOUNDS.nativeCandidateLimit;
|
|
const CANONICAL_RESULT_LIMIT_MAX = CANONICAL_SKILL_SEARCH_BOUNDS.resultLimit;
|
|
const ROLLING_ADOPTION_DAYS = CANONICAL_SKILL_SEARCH_BOUNDS.rollingAdoptionDays;
|
|
// Twenty candidates read at most 1,200 daily rows. Keep this well below the
|
|
// query CPU ceiling: production-shaped 40-skill batches had intermittent 1s timeouts.
|
|
const ROLLING_USAGE_QUERY_BATCH_SIZE = CANONICAL_SKILL_SEARCH_BOUNDS.rollingUsageBatchSize;
|
|
|
|
function chunkValues<T>(values: T[], size: number) {
|
|
const chunks: T[][] = [];
|
|
for (let index = 0; index < values.length; index += size) {
|
|
chunks.push(values.slice(index, index + size));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function parseQualifiedSearchIdentity(query: string) {
|
|
const normalized = query.trim().replace(/^@/, "").toLowerCase();
|
|
const external = normalized.startsWith("skills-sh:")
|
|
? normalized.slice("skills-sh:".length)
|
|
: normalized.startsWith("skills-sh/")
|
|
? normalized.slice("skills-sh/".length)
|
|
: normalized.includes("/")
|
|
? normalized
|
|
: null;
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
return {
|
|
native: segments.length === 2 ? { owner: segments[0], slug: segments[1] } : null,
|
|
external,
|
|
};
|
|
}
|
|
|
|
function canonicalScore(relevance: CanonicalSkillSearchCandidate["relevance"]) {
|
|
return (6 - relevance.tier) * 1_000 + relevance.lexicalScore + relevance.semanticScore;
|
|
}
|
|
|
|
function omitPublisherBio(owner: PublicPublisher | null) {
|
|
if (!owner) return null;
|
|
const { bio: _bio, ...ownerWithoutBio } = owner;
|
|
return ownerWithoutBio;
|
|
}
|
|
|
|
function buildNativeCanonicalResult(
|
|
entry: PublicSearchResult,
|
|
usage: RollingSkillUsage | undefined,
|
|
query: string,
|
|
): (CanonicalSkillSearchResult & CanonicalSkillSearchCandidate) | null {
|
|
const ownerHandle = entry.ownerHandle ?? entry.owner?.handle ?? null;
|
|
const identity = ownerHandle ? `${ownerHandle}/${entry.skill.slug}` : entry.skill.slug;
|
|
const relevance = classifyCanonicalSkillSearchMatch(query, {
|
|
identities: [identity, entry.skill.slug],
|
|
name: entry.skill.displayName,
|
|
slug: entry.skill.slug,
|
|
taxonomy: [...(entry.skill.categories ?? []), ...(entry.skill.topics ?? [])],
|
|
summary: entry.skill.summary ?? null,
|
|
semanticScore: entry.semanticScore,
|
|
});
|
|
if (!relevance) return null;
|
|
const official = Boolean(entry.owner?.official || entry.skill.badges?.official);
|
|
const featured = isSkillHighlighted(entry.skill);
|
|
const canonicalUrl = `/${encodeURIComponent(ownerHandle ?? String(entry.skill.ownerPublisherId ?? entry.skill.ownerUserId))}/skills/${encodeURIComponent(entry.skill.slug)}`;
|
|
const publisher = entry.owner
|
|
? {
|
|
kind: entry.owner.kind,
|
|
handle: entry.owner.handle ?? null,
|
|
displayName: entry.owner.displayName ?? null,
|
|
image: entry.owner.image ?? null,
|
|
official,
|
|
}
|
|
: null;
|
|
return {
|
|
id: `clawhub:${String(entry.skill._id)}`,
|
|
source: "clawhub",
|
|
relevance,
|
|
official,
|
|
featured,
|
|
rolling60DayInstalls: usage?.installs ?? 0,
|
|
bookmarks: usage?.bookmarks ?? 0,
|
|
updatedAt: entry.skill.updatedAt,
|
|
slug: entry.skill.slug,
|
|
displayName: entry.skill.displayName,
|
|
summary: entry.skill.summary ?? null,
|
|
icon: entry.skill.icon ?? null,
|
|
score: canonicalScore(relevance),
|
|
canonicalUrl,
|
|
links: { canonical: canonicalUrl, source: null },
|
|
publisher,
|
|
install: {
|
|
kind: entry.skill.installKind === "github" ? "github" : "clawhub",
|
|
reference: identity,
|
|
sourceUrl: null,
|
|
},
|
|
sourceIdentity: {
|
|
id: String(entry.skill._id),
|
|
owner: ownerHandle,
|
|
repo: null,
|
|
host: null,
|
|
lifetimeInstalls: null,
|
|
},
|
|
trust: {
|
|
visibility: "public",
|
|
installability: "installable",
|
|
clawHubVerdict: entry.skill.githubScanStatus ?? null,
|
|
upstreamScanners: null,
|
|
sourceFreshness: "native",
|
|
},
|
|
metrics: {
|
|
rolling60DayInstalls: usage?.installs ?? 0,
|
|
bookmarks: usage?.bookmarks ?? 0,
|
|
updatedAt: entry.skill.updatedAt,
|
|
},
|
|
native: {
|
|
skill: entry.skill,
|
|
version: entry.version,
|
|
owner: omitPublisherBio(entry.owner),
|
|
ownerHandle,
|
|
},
|
|
ownerHandle,
|
|
version: entry.version?.version ?? null,
|
|
downloads: entry.skill.stats.downloads,
|
|
};
|
|
}
|
|
|
|
function buildExternalCanonicalResult(
|
|
digest: Doc<"skillsShMirrorDigests">,
|
|
query: string,
|
|
): (CanonicalSkillSearchResult & CanonicalSkillSearchCandidate) | null {
|
|
const relevance = classifyCanonicalSkillSearchMatch(query, {
|
|
identities: [
|
|
digest.externalId,
|
|
`skills-sh:${digest.externalId}`,
|
|
`skills-sh/${digest.externalId}`,
|
|
],
|
|
name: digest.displayName,
|
|
slug: digest.slug,
|
|
taxonomy: [...(digest.inferredCategories ?? []), ...(digest.inferredTopics ?? [])],
|
|
summary: digest.searchSummary ?? null,
|
|
});
|
|
if (!relevance) return null;
|
|
const sourceOwner = digest.owner ?? digest.sourceHost ?? null;
|
|
const canonicalUrl = `/skills-sh/${digest.externalId
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/")}`;
|
|
return {
|
|
id: `skills-sh:${digest.externalId}`,
|
|
source: "skills-sh",
|
|
relevance,
|
|
official: false,
|
|
featured: false,
|
|
rolling60DayInstalls: 0,
|
|
bookmarks: 0,
|
|
updatedAt: digest.lastObservedAt,
|
|
slug: digest.slug,
|
|
displayName: digest.displayName,
|
|
summary: digest.searchSummary ?? null,
|
|
icon: null,
|
|
score: canonicalScore(relevance),
|
|
canonicalUrl,
|
|
links: { canonical: canonicalUrl, source: digest.sourceUrl },
|
|
publisher: null,
|
|
install: {
|
|
kind: "skills-sh",
|
|
reference: `skills-sh:${digest.externalId}`,
|
|
sourceUrl: digest.sourceUrl,
|
|
},
|
|
sourceIdentity: {
|
|
id: digest.externalId,
|
|
owner: digest.owner ?? null,
|
|
repo: digest.repo ?? null,
|
|
host: digest.sourceHost ?? null,
|
|
lifetimeInstalls: digest.upstreamInstalls,
|
|
},
|
|
trust: {
|
|
visibility: "public",
|
|
installability: "installable",
|
|
clawHubVerdict: null,
|
|
upstreamScanners: digest.upstreamScanners,
|
|
sourceFreshness: "observed-only",
|
|
},
|
|
metrics: {
|
|
rolling60DayInstalls: null,
|
|
bookmarks: null,
|
|
updatedAt: digest.lastObservedAt,
|
|
},
|
|
native: null,
|
|
ownerHandle: sourceOwner,
|
|
version: null,
|
|
downloads: digest.upstreamInstalls,
|
|
};
|
|
}
|
|
|
|
export const searchSkills: ReturnType<typeof action> = action({
|
|
args: skillSearchArgs,
|
|
handler: async (ctx, args): Promise<CanonicalSkillSearchResult[]> => {
|
|
const query = args.query.trim();
|
|
if (!query) return [];
|
|
const limit = Math.min(Math.max(Math.trunc(args.limit ?? 10), 1), CANONICAL_RESULT_LIMIT_MAX);
|
|
const qualified = parseQualifiedSearchIdentity(query);
|
|
const nativeArgs = {
|
|
...args,
|
|
limit: CANONICAL_NATIVE_CANDIDATE_LIMIT,
|
|
};
|
|
const [nativeMatches, qualifiedNativeMatches, externalMatches] = await Promise.all([
|
|
nativeSkillSearch.handler(ctx, nativeArgs),
|
|
qualified.native
|
|
? (ctx.runQuery(internal.search.getOwnerQualifiedSkillMatch, {
|
|
...qualified.native,
|
|
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
|
highlightedOnly: args.highlightedOnly,
|
|
categorySlug: args.categorySlug,
|
|
topic: args.topic,
|
|
}) as Promise<SkillSearchEntry[]>)
|
|
: Promise.resolve([]),
|
|
ctx.runQuery(internal.search.getExternalSkillSearchCandidates, {
|
|
query,
|
|
highlightedOnly: args.highlightedOnly,
|
|
categorySlug: args.categorySlug,
|
|
topic: args.topic,
|
|
...(qualified.external ? { exactExternalId: qualified.external } : {}),
|
|
}) as Promise<Doc<"skillsShMirrorDigests">[]>,
|
|
]);
|
|
|
|
const nativeById = new Map<string, PublicSearchResult>();
|
|
for (const entry of [...qualifiedNativeMatches, ...nativeMatches]) {
|
|
if (args.excludePendingScan && entry.skill.githubScanStatus === "pending") continue;
|
|
nativeById.set(String(entry.skill._id), {
|
|
...entry,
|
|
semanticScore: "semanticScore" in entry ? Number(entry.semanticScore) : 0,
|
|
score: "score" in entry ? Number(entry.score) : 0,
|
|
});
|
|
}
|
|
const nativeCandidates = [...nativeById.values()];
|
|
const endDay = toDayKey(Date.now());
|
|
const usageRows = (
|
|
await Promise.all(
|
|
chunkValues(
|
|
nativeCandidates.map((entry) => entry.skill._id),
|
|
ROLLING_USAGE_QUERY_BATCH_SIZE,
|
|
).map(
|
|
(skillIds) =>
|
|
ctx.runQuery(internal.search.getRollingSkillSearchUsage, {
|
|
skillIds,
|
|
startDay: endDay - (ROLLING_ADOPTION_DAYS - 1),
|
|
endDay,
|
|
}) as Promise<RollingSkillUsage[]>,
|
|
),
|
|
)
|
|
).flat();
|
|
const usageBySkill = new Map(usageRows.map((usage) => [String(usage.skillId), usage]));
|
|
|
|
const ranked = [
|
|
...nativeCandidates.map((entry) =>
|
|
buildNativeCanonicalResult(entry, usageBySkill.get(String(entry.skill._id)), query),
|
|
),
|
|
...externalMatches.map((digest) => buildExternalCanonicalResult(digest, query)),
|
|
]
|
|
.filter(
|
|
(result): result is CanonicalSkillSearchResult & CanonicalSkillSearchCandidate =>
|
|
result !== null,
|
|
)
|
|
.filter(
|
|
(result) => args.mode !== "exact" || result.slug.toLowerCase() === query.toLowerCase(),
|
|
)
|
|
.sort(compareCanonicalSkillSearchCandidates)
|
|
.slice(0, limit);
|
|
|
|
return ranked.map(
|
|
({
|
|
relevance: _relevance,
|
|
rolling60DayInstalls: _installs,
|
|
bookmarks: _bookmarks,
|
|
...result
|
|
}) => result,
|
|
);
|
|
},
|
|
});
|
|
|
|
export const getExactSkillSlugMatch = internalQuery({
|
|
args: {
|
|
slug: v.string(),
|
|
nonSuspiciousOnly: v.optional(v.boolean()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
officialOnly: v.optional(v.boolean()),
|
|
createdAfter: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
const skills = await ctx.db
|
|
.query("skills")
|
|
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
|
|
.take(MAX_EXACT_SLUG_MATCHES);
|
|
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
|
|
|
const entries = await Promise.all(
|
|
skills.map(async (skill) => {
|
|
if (skill.softDeletedAt) return null;
|
|
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
|
if (args.highlightedOnly && !isSkillHighlighted(skill)) return null;
|
|
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
|
|
if (!matchesNativeSearchEligibility(skill, args)) return null;
|
|
if (!(await hasResolvablePublicBrowseVersionFromState(ctx, skill, undefined))) return null;
|
|
|
|
const resolved = await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
|
const publicSkill = toPublicSearchSkill(skill);
|
|
if (!publicSkill || !resolved.owner) return null;
|
|
|
|
const entry: SkillSearchEntry = {
|
|
skill: publicSkill,
|
|
version: null as Doc<"skillVersions"> | null,
|
|
ownerHandle: resolved.ownerHandle,
|
|
owner: resolved.owner,
|
|
};
|
|
return entry;
|
|
}),
|
|
);
|
|
|
|
return entries.filter((entry): entry is SkillSearchEntry => entry !== null);
|
|
},
|
|
});
|
|
|
|
export const getOwnerQualifiedSkillMatch = internalQuery({
|
|
args: {
|
|
owner: v.string(),
|
|
slug: v.string(),
|
|
nonSuspiciousOnly: v.optional(v.boolean()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
const publisher = await getPublisherByHandle(ctx, args.owner);
|
|
let skill = publisher
|
|
? await ctx.db
|
|
.query("skills")
|
|
.withIndex("by_owner_publisher_slug", (q) =>
|
|
q.eq("ownerPublisherId", publisher._id).eq("slug", args.slug),
|
|
)
|
|
.unique()
|
|
: null;
|
|
if (!skill) {
|
|
const user = await getActiveUserByHandleOrPersonalPublisher(ctx, args.owner);
|
|
if (!user) return [];
|
|
skill = await ctx.db
|
|
.query("skills")
|
|
.withIndex("by_owner_slug", (q) => q.eq("ownerUserId", user._id).eq("slug", args.slug))
|
|
.unique();
|
|
}
|
|
if (!skill || skill.softDeletedAt) return [];
|
|
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return [];
|
|
if (args.highlightedOnly && !isSkillHighlighted(skill)) return [];
|
|
if (!matchesCatalogFilters(skill, categorySlug, topic)) return [];
|
|
if (!(await hasResolvablePublicBrowseVersionFromState(ctx, skill, undefined))) return [];
|
|
const directOwner =
|
|
publisher && skill.ownerPublisherId === publisher._id
|
|
? await toPublicPublisherWithOfficial(ctx, publisher)
|
|
: null;
|
|
const resolved = directOwner
|
|
? { ownerHandle: directOwner.handle ?? null, owner: directOwner }
|
|
: await makeOwnerInfoGetter(ctx)(skill.ownerUserId, skill.ownerPublisherId);
|
|
const publicSkill = toPublicSearchSkill(skill);
|
|
if (!resolved.owner || !publicSkill) return [];
|
|
return [
|
|
{
|
|
skill: publicSkill,
|
|
version: null,
|
|
ownerHandle: resolved.ownerHandle,
|
|
owner: resolved.owner,
|
|
},
|
|
];
|
|
},
|
|
});
|
|
|
|
function isPublicExternalSearchDigest(digest: Doc<"skillsShMirrorDigests">) {
|
|
return (
|
|
digest.active &&
|
|
digest.publicVisible &&
|
|
digest.installable &&
|
|
digest.sourceFreshnessStatus === "observed-only" &&
|
|
digest.tombstonedAt === undefined
|
|
);
|
|
}
|
|
|
|
const MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX =
|
|
CANONICAL_SKILL_SEARCH_BOUNDS.externalCandidateLimitPerIndex;
|
|
|
|
export const getExternalSkillSearchCandidates = internalQuery({
|
|
args: {
|
|
query: v.string(),
|
|
exactExternalId: v.optional(v.string()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args): Promise<Doc<"skillsShMirrorDigests">[]> => {
|
|
if (!(await getSkillsShPublicCatalogEnabledHandler(ctx))) return [];
|
|
if (args.highlightedOnly) return [];
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
const normalizedQuery = normalizeSkillSearchText(args.query);
|
|
if (!normalizedQuery) return [];
|
|
const firstToken = getFirstSearchToken(args.query);
|
|
const upperBound = prefixUpperBound(normalizedQuery);
|
|
const firstTokenUpperBound = firstToken ? prefixUpperBound(firstToken) : null;
|
|
|
|
const [exact, slug, displayName, slugFirstToken, displayNameFirstToken, fullText] =
|
|
await Promise.all([
|
|
args.exactExternalId
|
|
? ctx.db
|
|
.query("skillsShMirrorDigests")
|
|
.withIndex("by_external_id", (q) => q.eq("externalId", args.exactExternalId!))
|
|
.unique()
|
|
: Promise.resolve(null),
|
|
ctx.db
|
|
.query("skillsShMirrorDigests")
|
|
.withIndex("by_active_visible_installable_fresh_slug", (q) =>
|
|
q
|
|
.eq("active", true)
|
|
.eq("publicVisible", true)
|
|
.eq("installable", true)
|
|
.eq("sourceFreshnessStatus", "observed-only")
|
|
.gte("normalizedSlug", normalizedQuery)
|
|
.lt("normalizedSlug", upperBound),
|
|
)
|
|
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX),
|
|
ctx.db
|
|
.query("skillsShMirrorDigests")
|
|
.withIndex("by_active_visible_installable_fresh_display", (q) =>
|
|
q
|
|
.eq("active", true)
|
|
.eq("publicVisible", true)
|
|
.eq("installable", true)
|
|
.eq("sourceFreshnessStatus", "observed-only")
|
|
.gte("normalizedDisplayName", normalizedQuery)
|
|
.lt("normalizedDisplayName", upperBound),
|
|
)
|
|
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX),
|
|
firstTokenUpperBound
|
|
? ctx.db
|
|
.query("skillsShMirrorDigests")
|
|
.withIndex("by_active_visible_installable_fresh_slug_token", (q) =>
|
|
q
|
|
.eq("active", true)
|
|
.eq("publicVisible", true)
|
|
.eq("installable", true)
|
|
.eq("sourceFreshnessStatus", "observed-only")
|
|
.gte("normalizedSlugFirstToken", firstToken)
|
|
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
|
)
|
|
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX)
|
|
: Promise.resolve([]),
|
|
firstTokenUpperBound
|
|
? ctx.db
|
|
.query("skillsShMirrorDigests")
|
|
.withIndex("by_active_visible_installable_fresh_display_token", (q) =>
|
|
q
|
|
.eq("active", true)
|
|
.eq("publicVisible", true)
|
|
.eq("installable", true)
|
|
.eq("sourceFreshnessStatus", "observed-only")
|
|
.gte("normalizedDisplayNameFirstToken", firstToken)
|
|
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
|
)
|
|
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX)
|
|
: Promise.resolve([]),
|
|
ctx.db
|
|
.query("skillsShMirrorDigests")
|
|
.withSearchIndex("search_by_search_text", (q) =>
|
|
q
|
|
.search("searchText", args.query)
|
|
.eq("active", true)
|
|
.eq("publicVisible", true)
|
|
.eq("installable", true)
|
|
.eq("sourceFreshnessStatus", "observed-only"),
|
|
)
|
|
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX),
|
|
]);
|
|
|
|
const candidates = [
|
|
...(exact ? [exact] : []),
|
|
...slug,
|
|
...displayName,
|
|
...slugFirstToken,
|
|
...displayNameFirstToken,
|
|
...fullText,
|
|
];
|
|
const seen = new Set<string>();
|
|
return candidates.filter((digest) => {
|
|
if (seen.has(digest.externalId)) return false;
|
|
seen.add(digest.externalId);
|
|
if (!isPublicExternalSearchDigest(digest)) return false;
|
|
if (
|
|
categorySlug &&
|
|
!(digest.inferredCategories ?? []).some((category) => category === categorySlug)
|
|
) {
|
|
return false;
|
|
}
|
|
if (topic && !getCatalogTopicSlugs(digest.inferredTopics).includes(topic)) return false;
|
|
return true;
|
|
});
|
|
},
|
|
});
|
|
|
|
export const getRollingSkillSearchUsage = internalQuery({
|
|
args: {
|
|
skillIds: v.array(v.id("skills")),
|
|
startDay: v.number(),
|
|
endDay: v.number(),
|
|
},
|
|
handler: async (ctx, args): Promise<RollingSkillUsage[]> => {
|
|
if (args.skillIds.length > ROLLING_USAGE_QUERY_BATCH_SIZE) {
|
|
throw new Error(`skillIds exceeds ${ROLLING_USAGE_QUERY_BATCH_SIZE}`);
|
|
}
|
|
return await Promise.all(
|
|
args.skillIds.map(async (skillId) => {
|
|
const rows = await ctx.db
|
|
.query("skillDailyStats")
|
|
.withIndex("by_skill_day", (q) =>
|
|
q.eq("skillId", skillId).gte("day", args.startDay).lte("day", args.endDay),
|
|
)
|
|
.take(ROLLING_ADOPTION_DAYS);
|
|
return {
|
|
skillId,
|
|
installs: rows.reduce((total, row) => total + Math.max(0, row.installs), 0),
|
|
bookmarks: rows.reduce((total, row) => total + Math.max(0, row.bookmarks ?? 0), 0),
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
export const directPrefixSkillMatches = internalQuery({
|
|
args: {
|
|
query: v.string(),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
nonSuspiciousOnly: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
officialOnly: v.optional(v.boolean()),
|
|
createdAfter: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
const normalizedQuery = normalizeSkillSearchText(args.query);
|
|
if (!normalizedQuery) return [];
|
|
const firstToken = getFirstSearchToken(args.query);
|
|
const queryTokens = tokenize(args.query);
|
|
const topicQuery = normalizeCatalogTopic(args.query);
|
|
const exactRecallTopics = [
|
|
...new Set([topicQuery, topic].filter((value): value is string => !!value)),
|
|
];
|
|
const passesAllQueryTokens = (digest: Doc<"skillSearchDigest">) =>
|
|
queryTokens.length === 0 ||
|
|
matchesExactTokens(queryTokens, [
|
|
digest.displayName,
|
|
digest.slug,
|
|
digest.summary,
|
|
...(digest.categories ?? []),
|
|
...(digest.topics ?? []),
|
|
]);
|
|
const matchesDirectRecallFilters = (digest: Doc<"skillSearchDigest">) => {
|
|
const skill = digestToHydratableSkill(digest);
|
|
return (
|
|
!shouldExcludeSkillFromPublicBrowse(skill) &&
|
|
(!args.highlightedOnly || isSkillHighlighted(skill)) &&
|
|
matchesNativeSearchEligibility(skill, args) &&
|
|
passesAllQueryTokens(digest) &&
|
|
matchesCatalogFilters(skill, categorySlug, topic)
|
|
);
|
|
};
|
|
const needsExpandedRecall = Boolean(
|
|
categorySlug ||
|
|
topic ||
|
|
args.highlightedOnly ||
|
|
args.officialOnly ||
|
|
args.createdAfter !== undefined ||
|
|
queryTokens.length > 1,
|
|
);
|
|
const directScanLimit = (candidateLimit: number) =>
|
|
needsExpandedRecall ? MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES : candidateLimit;
|
|
const loadTopicDigests = async (recallTopic: string, usePrefix: boolean, limit: number) => {
|
|
const createQuery = () =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillTopicSearchDigest")
|
|
.withIndex("by_nonsuspicious_topic_updated", (q) =>
|
|
usePrefix
|
|
? q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false)
|
|
.gte("topic", recallTopic)
|
|
.lt("topic", prefixUpperBound(recallTopic))
|
|
: q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false)
|
|
.eq("topic", recallTopic),
|
|
)
|
|
.order("desc")
|
|
: ctx.db
|
|
.query("skillTopicSearchDigest")
|
|
.withIndex("by_active_topic_updated", (q) =>
|
|
usePrefix
|
|
? q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("topic", recallTopic)
|
|
.lt("topic", prefixUpperBound(recallTopic))
|
|
: q.eq("softDeletedAt", undefined).eq("topic", recallTopic),
|
|
)
|
|
.order("desc");
|
|
const scanLimit = needsExpandedRecall ? MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES : limit;
|
|
const rows = await createQuery().take(scanLimit);
|
|
const digests = await Promise.all(
|
|
rows.map((row) =>
|
|
ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_skill", (q) => q.eq("skillId", row.skillId))
|
|
.unique(),
|
|
),
|
|
);
|
|
return digests
|
|
.filter(
|
|
(digest): digest is Doc<"skillSearchDigest"> =>
|
|
digest !== null && matchesDirectRecallFilters(digest),
|
|
)
|
|
.slice(0, limit);
|
|
};
|
|
|
|
const upperBound = prefixUpperBound(normalizedQuery);
|
|
const firstTokenUpperBound = firstToken ? prefixUpperBound(firstToken) : null;
|
|
const collectDirectCandidates = (
|
|
createQuery: SkillDigestCandidateQueryFactory,
|
|
limit: number,
|
|
) =>
|
|
collectFilteredSkillDigestCandidates(createQuery, {
|
|
limit,
|
|
scanLimit: directScanLimit(limit),
|
|
matches: matchesDirectRecallFilters,
|
|
});
|
|
const [
|
|
slugDigests,
|
|
displayNameDigests,
|
|
slugFirstTokenDigests,
|
|
displayNameFirstTokenDigests,
|
|
ftDisplayNameDigests,
|
|
ftSlugDigests,
|
|
exactTopicDigestPages,
|
|
] = await Promise.all([
|
|
collectDirectCandidates(
|
|
() =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_nonsuspicious_normalized_slug", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false)
|
|
.gte("normalizedSlug", normalizedQuery)
|
|
.lt("normalizedSlug", upperBound),
|
|
)
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_normalized_slug", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("normalizedSlug", normalizedQuery)
|
|
.lt("normalizedSlug", upperBound),
|
|
),
|
|
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
|
),
|
|
collectDirectCandidates(
|
|
() =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_nonsuspicious_normalized_display_name", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false)
|
|
.gte("normalizedDisplayName", normalizedQuery)
|
|
.lt("normalizedDisplayName", upperBound),
|
|
)
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_normalized_display_name", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("normalizedDisplayName", normalizedQuery)
|
|
.lt("normalizedDisplayName", upperBound),
|
|
),
|
|
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
|
),
|
|
firstTokenUpperBound
|
|
? collectDirectCandidates(
|
|
() =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_nonsuspicious_normalized_slug_first_token", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false)
|
|
.gte("normalizedSlugFirstToken", firstToken)
|
|
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
|
)
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_normalized_slug_first_token", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("normalizedSlugFirstToken", firstToken)
|
|
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
|
),
|
|
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
|
)
|
|
: Promise.resolve([]),
|
|
firstTokenUpperBound
|
|
? collectDirectCandidates(
|
|
() =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_nonsuspicious_normalized_display_name_first_token", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false)
|
|
.gte("normalizedDisplayNameFirstToken", firstToken)
|
|
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
|
)
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_normalized_display_name_first_token", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("normalizedDisplayNameFirstToken", firstToken)
|
|
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
|
),
|
|
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
|
)
|
|
: Promise.resolve([]),
|
|
// Full-text search on displayName — matches any token at any position.
|
|
// Resolves Bug (non-first-token undiscoverable) by leveraging the
|
|
// Convex inverted index added in `search_by_display_name`.
|
|
collectDirectCandidates(
|
|
() =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withSearchIndex("search_by_display_name", (q) =>
|
|
q
|
|
.search("displayName", args.query)
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false),
|
|
)
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withSearchIndex("search_by_display_name", (q) =>
|
|
q.search("displayName", args.query).eq("softDeletedAt", undefined),
|
|
),
|
|
MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES,
|
|
),
|
|
// Full-text search on slug — same rationale, covers slug middle/tail tokens
|
|
// (e.g. "yijian" or "vision" inside "baidu-yijian-vision").
|
|
collectDirectCandidates(
|
|
() =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withSearchIndex("search_by_slug", (q) =>
|
|
q
|
|
.search("slug", args.query)
|
|
.eq("softDeletedAt", undefined)
|
|
.eq("isSuspicious", false),
|
|
)
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withSearchIndex("search_by_slug", (q) =>
|
|
q.search("slug", args.query).eq("softDeletedAt", undefined),
|
|
),
|
|
MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES,
|
|
),
|
|
Promise.all(
|
|
exactRecallTopics.map((recallTopic) =>
|
|
loadTopicDigests(recallTopic, false, MAX_DIRECT_SKILL_TOPIC_CANDIDATES),
|
|
),
|
|
),
|
|
]);
|
|
const queryExactTopicDigests = topicQuery
|
|
? (exactTopicDigestPages[exactRecallTopics.indexOf(topicQuery)] ?? [])
|
|
: [];
|
|
const prefixTopicDigests =
|
|
topicQuery && queryExactTopicDigests.length < MAX_DIRECT_SKILL_TOPIC_CANDIDATES
|
|
? await loadTopicDigests(
|
|
topicQuery,
|
|
true,
|
|
MAX_DIRECT_SKILL_TOPIC_CANDIDATES - queryExactTopicDigests.length,
|
|
)
|
|
: [];
|
|
const topicDigests = [...exactTopicDigestPages.flat(), ...prefixTopicDigests]
|
|
.flat()
|
|
.filter(
|
|
(digest, index, all) =>
|
|
all.findIndex((candidate) => candidate.skillId === digest.skillId) === index,
|
|
);
|
|
const digests = [
|
|
...slugDigests,
|
|
...displayNameDigests,
|
|
...slugFirstTokenDigests,
|
|
...displayNameFirstTokenDigests,
|
|
...ftDisplayNameDigests,
|
|
...ftSlugDigests,
|
|
...topicDigests,
|
|
]
|
|
.filter(
|
|
(digest, index, all) =>
|
|
all.findIndex((candidate) => candidate.skillId === digest.skillId) === index,
|
|
)
|
|
.filter(passesAllQueryTokens);
|
|
if (digests.length === 0) return [];
|
|
|
|
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
|
const entries = await Promise.all(
|
|
digests.map(async (digest): Promise<SkillSearchEntry | null> => {
|
|
const skill = digestToHydratableSkill(digest);
|
|
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
|
if (args.highlightedOnly && !isSkillHighlighted(skill)) return null;
|
|
if (!matchesNativeSearchEligibility(skill, args)) return null;
|
|
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
|
|
if (!(await hasResolvablePublicBrowseVersionFromState(ctx, skill, digest.publicVersion))) {
|
|
return null;
|
|
}
|
|
const preResolved = digestToOwnerInfo(digest);
|
|
const resolved = preResolved?.owner
|
|
? await withOfficialOwnerInfo(ctx, preResolved)
|
|
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
|
const publicSkill = toPublicSearchSkill(skill);
|
|
if (!publicSkill || !resolved.owner) return null;
|
|
return {
|
|
skill: publicSkill,
|
|
version: null as Doc<"skillVersions"> | null,
|
|
ownerHandle: resolved.ownerHandle,
|
|
owner: resolved.owner,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return entries.filter((entry): entry is SkillSearchEntry => entry !== null);
|
|
},
|
|
});
|
|
|
|
export const hydrateResults = internalQuery({
|
|
args: {
|
|
embeddingIds: v.array(v.id("skillEmbeddings")),
|
|
nonSuspiciousOnly: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
officialOnly: v.optional(v.boolean()),
|
|
createdAfter: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
// Only used as fallback when digest doesn't have owner data.
|
|
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
|
|
|
const entries: Array<SkillSearchEntry | null> = await Promise.all(
|
|
args.embeddingIds.map(async (embeddingId) => {
|
|
// Use lightweight lookup table (~100 bytes) instead of full embedding doc (~12KB).
|
|
const lookup = await ctx.db
|
|
.query("embeddingSkillMap")
|
|
.withIndex("by_embedding", (q) => q.eq("embeddingId", embeddingId))
|
|
.unique();
|
|
// Fallback to full embedding doc for rows not yet backfilled.
|
|
const skillId = lookup
|
|
? lookup.skillId
|
|
: await ctx.db.get(embeddingId).then((e) => e?.skillId);
|
|
if (!skillId) return null;
|
|
// Use lightweight digest (~800 bytes) instead of full skill doc (~3-5KB).
|
|
const digest = await ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_skill", (q) => q.eq("skillId", skillId))
|
|
.unique();
|
|
const skill: HydratableSkill | null = digest
|
|
? digestToHydratableSkill(digest)
|
|
: await ctx.db.get(skillId);
|
|
if (!skill || skill.softDeletedAt) return null;
|
|
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
|
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
|
|
if (!matchesNativeSearchEligibility(skill, args)) return null;
|
|
// Use pre-resolved owner from digest to avoid reading the users table.
|
|
// Fall back to live lookup when digest owner is null (deactivated/deleted user).
|
|
const preResolved = digest ? digestToOwnerInfo(digest) : null;
|
|
const resolved = preResolved?.owner
|
|
? await withOfficialOwnerInfo(ctx, preResolved)
|
|
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
|
if (!resolved.owner) return null;
|
|
if (
|
|
!(await hasResolvablePublicBrowseVersionFromState(
|
|
ctx,
|
|
{ ...skill, _id: skillId },
|
|
digest?.publicVersion,
|
|
))
|
|
)
|
|
return null;
|
|
const publicSkill = toPublicSearchSkill(skill);
|
|
if (!publicSkill) return null;
|
|
return {
|
|
embeddingId,
|
|
skill: publicSkill,
|
|
version: null as Doc<"skillVersions"> | null,
|
|
ownerHandle: resolved.ownerHandle,
|
|
owner: resolved.owner,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return entries.filter((entry): entry is SkillSearchEntry => entry !== null);
|
|
},
|
|
});
|
|
|
|
export const lexicalFallbackSkills = internalQuery({
|
|
args: {
|
|
query: v.string(),
|
|
queryTokens: v.array(v.string()),
|
|
limit: v.optional(v.number()),
|
|
highlightedOnly: v.optional(v.boolean()),
|
|
nonSuspiciousOnly: v.optional(v.boolean()),
|
|
excludePendingScan: v.optional(v.boolean()),
|
|
skipExactSlugLookup: v.optional(v.boolean()),
|
|
categorySlug: v.optional(v.string()),
|
|
topic: v.optional(v.string()),
|
|
officialOnly: v.optional(v.boolean()),
|
|
createdAfter: v.optional(v.number()),
|
|
},
|
|
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
|
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
|
if (categorySlug === null) return [];
|
|
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
|
if (args.topic !== undefined && !topic) return [];
|
|
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
|
|
const scanLimit = limit;
|
|
const seenSkillIds = new Set<Id<"skills">>();
|
|
const candidates: HydratableSkill[] = [];
|
|
// Keep digest rows around so we can resolve owner info without hitting users table.
|
|
const preResolvedOwners = new Map<
|
|
Id<"skills">,
|
|
{ ownerHandle: string | null; owner: PublicPublisher | null }
|
|
>();
|
|
const publicVersions = new Map<Id<"skills">, Doc<"skillSearchDigest">["publicVersion"]>();
|
|
|
|
// Exact slug matches via the skills table. Slugs are unique per publisher,
|
|
// so this read must tolerate multiple rows for the same global slug.
|
|
// Use the lenient shape predicate so legacy rows with sub-min-length
|
|
// slugs stay discoverable; the caller in searchSkills already passes
|
|
// skipExactSlugLookup=true after running its own exact-slug lookup.
|
|
const slugQuery = normalizeSkillSlug(args.query);
|
|
if (!args.skipExactSlugLookup && isSearchableSkillSlugShape(slugQuery)) {
|
|
const exactSlugSkills = await ctx.db
|
|
.query("skills")
|
|
.withIndex("by_slug", (q) => q.eq("slug", slugQuery))
|
|
.take(MAX_EXACT_SLUG_MATCHES);
|
|
for (const exactSlugSkill of exactSlugSkills) {
|
|
if (
|
|
!shouldExcludeSkillFromPublicBrowse(exactSlugSkill) &&
|
|
(!args.nonSuspiciousOnly || !isSkillSuspicious(exactSlugSkill)) &&
|
|
(!args.excludePendingScan || exactSlugSkill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(exactSlugSkill, args) &&
|
|
matchesCatalogFilters(exactSlugSkill, categorySlug, topic)
|
|
) {
|
|
seenSkillIds.add(exactSlugSkill._id);
|
|
candidates.push(exactSlugSkill);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan recent active digests (~800 bytes each) instead of full skill docs (~3-5KB).
|
|
// Use updatedAt and createdAt windows so newly published skills are visible even
|
|
// when they are not in the most recently updated slice.
|
|
const createRecentByUpdatedQuery = () =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_nonsuspicious_updated", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
|
)
|
|
.order("desc")
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
|
.order("desc");
|
|
const createRecentByCreatedQuery = () =>
|
|
args.nonSuspiciousOnly
|
|
? ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_nonsuspicious_created", (q) =>
|
|
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
|
)
|
|
.order("desc")
|
|
: ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined))
|
|
.order("desc");
|
|
|
|
const filteredScanLimit =
|
|
categorySlug ||
|
|
topic ||
|
|
args.highlightedOnly ||
|
|
args.officialOnly ||
|
|
args.createdAfter !== undefined
|
|
? FALLBACK_SCAN_LIMIT
|
|
: scanLimit;
|
|
const matchesFallbackRecallFilters = (digest: Doc<"skillSearchDigest">) => {
|
|
const skill = digestToHydratableSkill(digest);
|
|
return (
|
|
!shouldExcludeSkillFromPublicBrowse(skill) &&
|
|
(!args.highlightedOnly || isSkillHighlighted(skill)) &&
|
|
(!args.excludePendingScan || skill.githubScanStatus !== "pending") &&
|
|
matchesNativeSearchEligibility(skill, args) &&
|
|
matchesCatalogFilters(skill, categorySlug, topic) &&
|
|
matchesExactTokens(args.queryTokens, [
|
|
skill.displayName,
|
|
skill.slug,
|
|
skill.summary,
|
|
...(skill.categories ?? []),
|
|
...(skill.topics ?? []),
|
|
])
|
|
);
|
|
};
|
|
const [recentByUpdated, recentByCreated] = await Promise.all([
|
|
collectFilteredSkillDigestCandidates(createRecentByUpdatedQuery, {
|
|
limit: scanLimit,
|
|
scanLimit: filteredScanLimit,
|
|
matches: matchesFallbackRecallFilters,
|
|
}),
|
|
collectFilteredSkillDigestCandidates(createRecentByCreatedQuery, {
|
|
limit: scanLimit,
|
|
scanLimit: filteredScanLimit,
|
|
matches: matchesFallbackRecallFilters,
|
|
}),
|
|
]);
|
|
|
|
const addDigestCandidates = (digests: typeof recentByUpdated) => {
|
|
for (const digest of digests) {
|
|
if (seenSkillIds.has(digest.skillId)) continue;
|
|
const skill = digestToHydratableSkill(digest);
|
|
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) continue;
|
|
if (args.excludePendingScan && skill.githubScanStatus === "pending") continue;
|
|
if (!matchesNativeSearchEligibility(skill, args)) continue;
|
|
if (!matchesCatalogFilters(skill, categorySlug, topic)) continue;
|
|
seenSkillIds.add(digest.skillId);
|
|
candidates.push(skill);
|
|
// Pre-resolve owner from digest to avoid users table reads.
|
|
const ownerInfo = digestToOwnerInfo(digest);
|
|
if (ownerInfo) preResolvedOwners.set(digest.skillId, ownerInfo);
|
|
publicVersions.set(digest.skillId, digest.publicVersion);
|
|
}
|
|
};
|
|
addDigestCandidates(recentByUpdated);
|
|
addDigestCandidates(recentByCreated);
|
|
|
|
const matched = candidates.filter((skill) =>
|
|
matchesExactTokens(args.queryTokens, [
|
|
skill.displayName,
|
|
skill.slug,
|
|
skill.summary,
|
|
...(skill.categories ?? []),
|
|
...(skill.topics ?? []),
|
|
]),
|
|
);
|
|
if (matched.length === 0) return [];
|
|
|
|
// Only used as fallback for the exact slug match (no digest available).
|
|
const getOwnerInfo = makeOwnerInfoGetter(ctx);
|
|
|
|
const entries = await Promise.all(
|
|
matched.map(async (skill) => {
|
|
const preResolved = preResolvedOwners.get(skill._id);
|
|
const resolved = preResolved?.owner
|
|
? await withOfficialOwnerInfo(ctx, preResolved)
|
|
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
|
if (!resolved.owner) return null;
|
|
if (
|
|
!(await hasResolvablePublicBrowseVersionFromState(
|
|
ctx,
|
|
{ ...skill, _id: skill._id },
|
|
publicVersions.get(skill._id),
|
|
))
|
|
)
|
|
return null;
|
|
const publicSkill = toPublicSearchSkill(skill);
|
|
if (!publicSkill) return null;
|
|
return {
|
|
skill: publicSkill,
|
|
version: null as Doc<"skillVersions"> | null,
|
|
ownerHandle: resolved.ownerHandle,
|
|
owner: resolved.owner,
|
|
};
|
|
}),
|
|
);
|
|
const validEntries = entries.filter(Boolean) as SkillSearchEntry[];
|
|
if (validEntries.length === 0) return [];
|
|
|
|
const filtered = validEntries.filter(
|
|
(entry) =>
|
|
(!args.highlightedOnly || isSkillHighlighted(entry.skill)) &&
|
|
matchesNativeSearchEligibility(entry.skill, args),
|
|
);
|
|
return filtered.slice(0, limit);
|
|
},
|
|
});
|
|
|
|
export const __test = {
|
|
getNextCandidateLimit,
|
|
matchesAllTokens,
|
|
getLexicalBoost,
|
|
scoreSkillResult,
|
|
classifySkillMatch,
|
|
mergeUniqueBySkillId,
|
|
parseQualifiedSearchIdentity,
|
|
};
|