feat(search): add freshness-aware discovery ranking

feat(search): add freshness-aware discovery ranking
This commit is contained in:
Vincent Koc
2026-06-25 15:11:24 +08:00
committed by GitHub
parent 3e6ad6b3b5
commit 11d70e3f88
27 changed files with 633 additions and 101 deletions
+1
View File
@@ -5,6 +5,7 @@
### Changes
- Web: organization publishers can upload durable PNG, JPEG, or WebP logos from settings instead of relying on hotlinked image URLs.
- Web/API: make default skill and plugin discovery freshness-aware, add seven-day trending views for both catalogs, and use verified status plus usage as search tie-breakers within direct matches.
## 0.23.0 - 2026-06-23
+2
View File
@@ -133,6 +133,7 @@ import type * as packageInspectorHttp from "../packageInspectorHttp.js";
import type * as packageInspectorNode from "../packageInspectorNode.js";
import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as packageLeaderboards from "../packageLeaderboards.js";
import type * as publisherAbuse from "../publisherAbuse.js";
import type * as publisherAbuseDevSeed from "../publisherAbuseDevSeed.js";
import type * as publishers from "../publishers.js";
@@ -287,6 +288,7 @@ declare const fullApi: ApiFromModules<{
packageInspectorNode: typeof packageInspectorNode;
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
packageLeaderboards: typeof packageLeaderboards;
publisherAbuse: typeof publisherAbuse;
publisherAbuseDevSeed: typeof publisherAbuseDevSeed;
publishers: typeof publishers;
+4
View File
@@ -32,8 +32,12 @@ vi.mock("./_generated/api", () => ({
internal: {
githubSkillSyncNode: { syncGitHubSkillSourcesInternal: mocks.githubSkillSyncRef },
leaderboards: { rebuildTrendingLeaderboardAction: Symbol("trending-leaderboard") },
packageLeaderboards: {
rebuildTrendingLeaderboardAction: Symbol("package-trending-leaderboard"),
},
statsMaintenance: {
runSkillStatBackfillInternal: Symbol("skill-stats-backfill"),
runRecommendationScoreBackfillInternal: Symbol("recommendation-score-refresh"),
updateGlobalStatsAction: Symbol("global-stats-update"),
},
skillStatEvents: {
+14
View File
@@ -19,6 +19,20 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") {
{ limit: 200 },
);
crons.interval(
"package-trending-leaderboard",
{ minutes: 60 },
internal.packageLeaderboards.rebuildTrendingLeaderboardAction,
{ limit: 200 },
);
crons.interval(
"recommendation-score-refresh",
{ hours: 6 },
internal.statsMaintenance.runRecommendationScoreBackfillInternal,
{ batchSize: 500, maxBatches: 50 },
);
crons.interval(
"skill-stats-backfill",
{ hours: 6 },
+40 -1
View File
@@ -310,7 +310,13 @@ async function getOptionalViewerUserIdForRequest(ctx: ActionCtx, request: Reques
const PACKAGE_FAMILY_VALUES = ["skill", "code-plugin", "bundle-plugin"] as const;
const PLUGIN_EXPORT_FAMILY_VALUES = ["code-plugin", "bundle-plugin"] as const;
const PACKAGE_CHANNEL_VALUES = ["official", "community", "private"] as const;
const PACKAGE_LIST_SORT_VALUES = ["updated", "recommended", "downloads", "installs"] as const;
const PACKAGE_LIST_SORT_VALUES = [
"updated",
"recommended",
"downloads",
"installs",
"trending",
] as const;
const PACKAGE_SCAN_STATUS_VALUES = [
"clean",
"suspicious",
@@ -1587,6 +1593,13 @@ async function listPackages(
rate.headers,
);
}
if (effectiveSort === "trending" && includeSkills) {
return text(
"Trending sort is only supported for plugin package endpoints; use /api/v1/skills?sort=trending for skills.",
400,
rate.headers,
);
}
if (effectiveFamily === "skill") {
const cursor = isLegacyInstallSortRequest
@@ -1737,6 +1750,32 @@ async function listPackages(
);
}
if (!effectiveFamily && options?.pluginFamilies?.length && effectiveSort === "trending") {
const result = await runQueryRef<{
page: CatalogListItem[];
isDone: boolean;
continueCursor: string | null;
}>(ctx, internalRefs.packages.listPageForViewerInternal, {
channel: channelParam.value,
isOfficial: isOfficial.value,
highlightedOnly: highlightedOnly || undefined,
category,
topic,
excludedScanStatuses: excludedScanStatuses.value,
sort: "trending",
viewerUserId: viewerUserId ?? undefined,
paginationOpts: { cursor: rawCursor, numItems: limit },
});
return json(
{
items: result.page,
nextCursor: result.isDone ? null : result.continueCursor,
},
200,
rate.headers,
);
}
if (!effectiveFamily && options?.pluginFamilies?.length) {
const shouldMarkDefaultDownloadCursor =
!sortParam.value && pluginDefaultSort === RECOMMENDED_FALLBACK_SORT;
+28
View File
@@ -44,4 +44,32 @@ describe("recommendationScore", () => {
expect(secondThousand - firstThousand).toBeLessThan(firstThousand);
});
it("gives fresh items a bounded discovery boost", () => {
const now = Date.UTC(2026, 5, 25);
const fresh = computeRecommendationScore(
{ downloads: 0, installs: 0, stars: 0 },
{ createdAt: now, updatedAt: now, now },
);
const stale = computeRecommendationScore(
{ downloads: 0, installs: 0, stars: 0 },
{ createdAt: now - 365 * 86_400_000, updatedAt: now - 365 * 86_400_000, now },
);
expect(fresh).toBeGreaterThan(stale);
expect(fresh).toBeLessThan(200);
});
it("reduces historical download dominance", () => {
const recentInstall = computeRecommendationScore(
{ downloads: 100, installs: 100, stars: 5 },
{ updatedAt: Date.UTC(2026, 5, 25), now: Date.UTC(2026, 5, 25) },
);
const staleDownload = computeRecommendationScore(
{ downloads: 1_000, installs: 0, stars: 0 },
{ updatedAt: Date.UTC(2024, 0, 1), now: Date.UTC(2026, 5, 25) },
);
expect(recentInstall).toBeGreaterThan(staleDownload);
});
});
+36 -6
View File
@@ -4,23 +4,53 @@ export type RecommendationStats = {
stars: number;
};
const DOWNLOAD_WEIGHT = 100;
const INSTALL_WEIGHT = 160;
const STAR_WEIGHT = 120;
export type RecommendationContext = {
createdAt?: number;
updatedAt?: number;
now?: number;
};
const DOWNLOAD_WEIGHT = 55;
const INSTALL_WEIGHT = 150;
const STAR_WEIGHT = 115;
const FRESHNESS_WEIGHT = 70;
const NOVELTY_WEIGHT = 100;
const FRESHNESS_HALF_LIFE_DAYS = 120;
const NOVELTY_WINDOW_DAYS = 45;
const DAY_MS = 24 * 60 * 60 * 1_000;
// Bump this when changing weights, then run statsMaintenance:runRecommendationScoreBackfillInternal.
export const RECOMMENDATION_SCORE_VERSION = 3;
export const RECOMMENDATION_SCORE_VERSION = 4;
function safeCount(value: number) {
if (!Number.isFinite(value) || value <= 0) return 0;
return value;
}
export function computeRecommendationScore(stats: RecommendationStats) {
function getAgeDays(timestamp: number | undefined, now: number) {
if (!Number.isFinite(timestamp)) return null;
return Math.max(0, (now - (timestamp as number)) / DAY_MS);
}
export function computeRecommendationScore(
stats: RecommendationStats,
context: RecommendationContext = {},
) {
const downloads = Math.sqrt(safeCount(stats.downloads)) * DOWNLOAD_WEIGHT;
const installs = Math.sqrt(safeCount(stats.installs)) * INSTALL_WEIGHT;
const stars = Math.sqrt(safeCount(stats.stars)) * STAR_WEIGHT;
return Math.round(downloads + installs + stars);
const now = Number.isFinite(context.now) ? (context.now as number) : Date.now();
const updatedAgeDays = getAgeDays(context.updatedAt, now);
const createdAgeDays = getAgeDays(context.createdAt, now);
const freshness =
updatedAgeDays === null
? 0
: Math.exp((-Math.LN2 * updatedAgeDays) / FRESHNESS_HALF_LIFE_DAYS) * FRESHNESS_WEIGHT;
const novelty =
createdAgeDays === null || createdAgeDays > NOVELTY_WINDOW_DAYS
? 0
: (1 - createdAgeDays / NOVELTY_WINDOW_DAYS) * NOVELTY_WEIGHT;
return Math.round(downloads + installs + stars + freshness + novelty);
}
export function compareRecommendationStats(a: RecommendationStats, b: RecommendationStats) {
+4
View File
@@ -134,6 +134,10 @@ export const RETENTION_POLICIES = {
retention: "Processed and older than 7 days.",
}),
packageDailyStats: permanent("Daily aggregate package stats are product analytics."),
packageLeaderboards: derived(
"Package trending snapshots can be rebuilt from packageDailyStats.",
"packageDailyStats",
),
packageTrustedPublishers: permanent("Trusted publishing configuration."),
packagePublishTokens: ephemeral("Package publish tokens expire and can be revoked.", {
expirationField: "expiresAt",
+11 -5
View File
@@ -93,11 +93,17 @@ export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFiel
statsStars,
statsInstallsCurrent,
statsInstallsAllTime,
recommendedScore: computeRecommendationScore({
downloads: statsDownloads,
installs: statsInstallsAllTime,
stars: statsStars,
}),
recommendedScore: computeRecommendationScore(
{
downloads: statsDownloads,
installs: statsInstallsAllTime,
stars: statsStars,
},
{
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
},
),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
skillId: skill._id,
normalizedSlug: normalizeSkillSearchText(skill.slug),
+72
View File
@@ -0,0 +1,72 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import {
rebuildTrendingLeaderboardAction,
rebuildTrendingLeaderboardInternal,
} from "./packageLeaderboards";
const mutationHandler = (
rebuildTrendingLeaderboardInternal as unknown as {
_handler: (ctx: unknown, args: { limit?: number }) => Promise<unknown>;
}
)._handler;
const actionHandler = (
rebuildTrendingLeaderboardAction as unknown as {
_handler: (ctx: unknown, args: { limit?: number }) => Promise<unknown>;
}
)._handler;
describe("packageLeaderboards", () => {
it("schedules a bounded leaderboard rebuild", async () => {
const runAfter = vi.fn().mockResolvedValue("job-1");
const result = await mutationHandler(
{
db: {
get: vi.fn(),
insert: vi.fn(),
normalizeId: vi.fn(),
patch: vi.fn(),
query: vi.fn(),
replace: vi.fn(),
system: { get: vi.fn(), query: vi.fn() },
delete: vi.fn(),
},
scheduler: { runAfter },
},
{ limit: 500 },
);
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), { limit: 200 });
expect(result).toEqual({ ok: true, count: 0, scheduled: true, days: 7 });
});
it("aggregates recent installs and downloads into a weighted top list", async () => {
const runQuery = vi.fn(async (_ref: unknown, args: Record<string, unknown>) => {
if (args.day === Math.floor(Date.now() / 86_400_000)) {
return {
rows: [
{ packageId: "packages:one", installs: 2, downloads: 1 },
{ packageId: "packages:two", installs: 0, downloads: 8 },
],
isDone: true,
continueCursor: "",
};
}
return { rows: [], isDone: true, continueCursor: "" };
});
const runMutation = vi.fn().mockResolvedValue({ ok: true });
const result = await actionHandler({ runQuery, runMutation }, { limit: 5 });
expect(result).toEqual({ ok: true, count: 2 });
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
items: [
expect.objectContaining({ packageId: "packages:two", score: 8 }),
expect.objectContaining({ packageId: "packages:one", score: 7 }),
],
}),
);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction, internalMutation, internalQuery } from "./functions";
import { getTrendingRange, topN, TRENDING_DAYS } from "./lib/leaderboards";
const MAX_TRENDING_LIMIT = 200;
const DAILY_STATS_PAGE_SIZE = 1_000;
const KEEP_LEADERBOARD_ENTRIES = 3;
export const PACKAGE_TRENDING_LEADERBOARD_KIND = "package_trending";
export const getDailyStatsPage = internalQuery({
args: {
day: v.number(),
cursor: v.union(v.string(), v.null()),
limit: v.optional(v.number()),
},
handler: async (ctx, { day, cursor, limit }) => {
const page = await ctx.db
.query("packageDailyStats")
.withIndex("by_day", (q) => q.eq("day", day))
.paginate({
cursor,
numItems: Math.min(limit ?? DAILY_STATS_PAGE_SIZE, DAILY_STATS_PAGE_SIZE),
});
return {
rows: page.page.map((row) => ({
packageId: row.packageId,
installs: row.installs,
downloads: row.downloads,
})),
isDone: page.isDone,
continueCursor: page.continueCursor,
};
},
});
export const writeTrendingLeaderboard = internalMutation({
args: {
items: v.array(
v.object({
packageId: v.id("packages"),
score: v.number(),
installs: v.number(),
downloads: v.number(),
}),
),
startDay: v.number(),
endDay: v.number(),
},
handler: async (ctx, { items, startDay, endDay }) => {
await ctx.db.insert("packageLeaderboards", {
kind: PACKAGE_TRENDING_LEADERBOARD_KIND,
generatedAt: Date.now(),
rangeStartDay: startDay,
rangeEndDay: endDay,
items,
});
const recent = await ctx.db
.query("packageLeaderboards")
.withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND))
.order("desc")
.take(KEEP_LEADERBOARD_ENTRIES + 5);
for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) {
await ctx.db.delete(entry._id);
}
return { ok: true as const, count: items.length };
},
});
export const rebuildTrendingLeaderboardAction = internalAction({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args): Promise<{ ok: true; count: number }> => {
const limit = Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT);
const now = Date.now();
const { startDay, endDay } = getTrendingRange(now);
const totals = new Map<Id<"packages">, { installs: number; downloads: number }>();
for (let day = startDay; day <= endDay; day += 1) {
let cursor: string | null = null;
let isDone = false;
while (!isDone) {
const page = (await ctx.runQuery(internal.packageLeaderboards.getDailyStatsPage, {
day,
cursor,
limit: DAILY_STATS_PAGE_SIZE,
})) as {
rows: Array<{ packageId: Id<"packages">; installs: number; downloads: number }>;
isDone: boolean;
continueCursor: string;
};
for (const row of page.rows) {
const current = totals.get(row.packageId) ?? { installs: 0, downloads: 0 };
current.installs += row.installs;
current.downloads += row.downloads;
totals.set(row.packageId, current);
}
cursor = page.continueCursor;
isDone = page.isDone;
}
}
const entries = Array.from(totals, ([packageId, entry]) => ({
packageId,
installs: entry.installs,
downloads: entry.downloads,
score: entry.installs * 3 + entry.downloads,
})).sort((a, b) => b.score - a.score || b.downloads - a.downloads || b.installs - a.installs);
await ctx.runMutation(internal.packageLeaderboards.writeTrendingLeaderboard, {
items: topN(
entries,
limit,
(a, b) => a.score - b.score || a.downloads - b.downloads || a.installs - b.installs,
).sort((a, b) => b.score - a.score || b.downloads - a.downloads || b.installs - a.installs),
startDay,
endDay,
});
return { ok: true as const, count: Math.min(entries.length, limit) };
},
});
export const rebuildTrendingLeaderboardInternal = internalMutation({
args: { limit: v.optional(v.number()) },
handler: async (ctx, args) => {
await ctx.scheduler.runAfter(0, internal.packageLeaderboards.rebuildTrendingLeaderboardAction, {
limit: Math.min(Math.max(args.limit ?? MAX_TRENDING_LIMIT, 1), MAX_TRENDING_LIMIT),
});
return { ok: true as const, count: 0, scheduled: true as const, days: TRENDING_DAYS };
},
});
+2 -1
View File
@@ -6446,7 +6446,8 @@ describe("packages public queries", () => {
isOfficial: false,
tags: {},
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
recommendedScore: 0,
recommendedScore: 170,
recommendedScoreVersion: 4,
}),
);
expect(insert).not.toHaveBeenCalledWith("packageReleases", expect.anything());
+119 -31
View File
@@ -117,6 +117,7 @@ import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./l
import { hashSkillFiles } from "./lib/skills";
import { buildDeterministicPackageZip } from "./lib/skillZip";
import { runStaticPublishScan } from "./lib/staticPublishScan";
import { PACKAGE_TRENDING_LEADERBOARD_KIND } from "./packageLeaderboards";
import schema from "./schema";
const MAX_PUBLIC_LIST_PAGE_SIZE = 200;
@@ -150,17 +151,26 @@ const INITIAL_PACKAGE_VT_SCAN_DELAY_MS = 30_000;
const PLUGIN_EXPORT_FAMILIES = ["code-plugin", "bundle-plugin"] as const;
const GET_PAGE_TIEBREAKER_FIELD_COUNT = 2;
function computePackageRecommendationScore(stats: Doc<"packages">["stats"]) {
return computeRecommendationScore({
downloads: stats.downloads,
installs: stats.installs,
stars: stats.stars,
});
function computePackageRecommendationScore(
stats: Doc<"packages">["stats"],
context?: { createdAt?: number; updatedAt?: number; now?: number },
) {
return computeRecommendationScore(
{
downloads: stats.downloads,
installs: stats.installs,
stars: stats.stars,
},
context,
);
}
function computePackageRecommendationPatch(stats: Doc<"packages">["stats"]) {
function computePackageRecommendationPatch(
stats: Doc<"packages">["stats"],
context?: { createdAt?: number; updatedAt?: number; now?: number },
) {
return {
recommendedScore: computePackageRecommendationScore(stats),
recommendedScore: computePackageRecommendationScore(stats, context),
recommendedScoreVersion: RECOMMENDATION_SCORE_VERSION,
};
}
@@ -901,7 +911,7 @@ type PublicPageCursorState = {
pageSize: number | null;
done: boolean;
mode?: "packages" | "digest";
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
packageIndex?: "family-official-downloads";
};
const PUBLIC_PAGE_CURSOR_PREFIX = "pkgpage:";
@@ -1548,7 +1558,8 @@ function decodePublicPageCursor(raw: string | null | undefined): PublicPageCurso
parsed.sort === "updated" ||
parsed.sort === "downloads" ||
parsed.sort === "recommended" ||
parsed.sort === "installs"
parsed.sort === "installs" ||
parsed.sort === "trending"
? parsed.sort
: undefined,
packageIndex:
@@ -1683,12 +1694,30 @@ function packageSearchMatch(
}
function comparePackageSearchMatches<
T extends PackageSearchMatch & { package: Pick<PackageDigestLike, "isOfficial" | "updatedAt"> },
T extends PackageSearchMatch & {
package: {
isOfficial: boolean;
updatedAt: number;
verificationTier?: PackageDigestLike["verificationTier"] | null;
stats?: { downloads: number; installs: number; stars: number } | null;
};
},
>(a: T, b: T) {
const verificationRank = (tier: PackageDigestLike["verificationTier"] | null) => {
if (tier === "rebuild-verified") return 4;
if (tier === "provenance-verified") return 3;
if (tier === "source-linked") return 2;
if (tier === "structural") return 1;
return 0;
};
return (
a.rankTier - b.rankTier ||
b.score - a.score ||
Number(b.package.isOfficial) - Number(a.package.isOfficial) ||
verificationRank(b.package.verificationTier) - verificationRank(a.package.verificationTier) ||
(b.package.stats?.stars ?? 0) - (a.package.stats?.stars ?? 0) ||
(b.package.stats?.installs ?? 0) - (a.package.stats?.installs ?? 0) ||
(b.package.stats?.downloads ?? 0) - (a.package.stats?.downloads ?? 0) ||
b.package.updatedAt - a.package.updatedAt
);
}
@@ -1888,7 +1917,7 @@ function buildPackagePluginCategoryDigestQuery(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
},
) {
const family = args.family;
@@ -2114,7 +2143,7 @@ function buildPackageTopicDigestQuery(
family?: PackageFamily;
channel?: PackageChannel;
isOfficial?: boolean;
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
},
) {
const family = args.family;
@@ -2291,7 +2320,7 @@ async function mayHaveVisiblePackageCategoryDigest(
category: PluginCategorySlug;
topic?: string;
excludedScanStatuses?: PackageListScanStatus[];
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
viewerUserId?: Id<"users">;
},
) {
@@ -2337,7 +2366,7 @@ async function takeVisiblePackageCategoryDigestPage(
category: PluginCategorySlug;
topic?: string;
excludedScanStatuses?: PackageListScanStatus[];
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
viewerUserId?: Id<"users">;
numItems: number;
},
@@ -2440,7 +2469,7 @@ async function fetchHighlightedPackagePage(
category?: string;
topic?: string;
officialFirst?: boolean;
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
viewerUserId?: Id<"users">;
numItems: number;
},
@@ -3062,6 +3091,7 @@ export const listPublicPage = query({
v.literal("downloads"),
v.literal("recommended"),
v.literal("installs"),
v.literal("trending"),
),
),
paginationOpts: paginationOptsValidator,
@@ -3539,6 +3569,7 @@ export const listPageForViewerInternal = internalQuery({
v.literal("downloads"),
v.literal("recommended"),
v.literal("installs"),
v.literal("trending"),
),
),
viewerUserId: v.optional(v.id("users")),
@@ -3592,7 +3623,7 @@ async function listPackagePageImpl(
topic?: string;
officialFirst?: boolean;
excludedScanStatuses?: PackageListScanStatus[];
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
viewerUserId?: Id<"users">;
paginationOpts: { cursor: string | null; numItems: number };
},
@@ -3615,6 +3646,45 @@ async function listPackagePageImpl(
return { page: [], isDone: true, continueCursor: "" };
}
if (args.sort === "trending") {
const leaderboard = await ctx.db
.query("packageLeaderboards")
.withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND))
.order("desc")
.first();
if (!leaderboard) return { page: [], isDone: true, continueCursor: "" };
const cursorState = decodePublicPageCursor(args.paginationOpts.cursor);
const startIndex = cursorState.sort === "trending" ? cursorState.offset : 0;
const page: PublicPackageListItem[] = [];
let nextOffset = startIndex;
for (let index = startIndex; index < leaderboard.items.length; index += 1) {
const entry = leaderboard.items[index];
nextOffset = index + 1;
const pkg = await ctx.db.get(entry.packageId);
if (!pkg || pkg.softDeletedAt) continue;
if (!(await canViewerReadPackage(ctx, pkg, viewerUserId, membershipCache))) continue;
if (!packageMatchesListFilters(pkg, { ...args, category, topic })) continue;
page.push(await toPublicPackageListItemFromPackage(ctx, pkg));
if (page.length >= targetCount) break;
}
const isDone = nextOffset >= leaderboard.items.length;
return {
page,
isDone,
continueCursor: isDone
? ""
: encodePublicPageCursor({
cursor: null,
offset: nextOffset,
pageSize: targetCount,
done: false,
mode: "packages",
sort: "trending",
}),
};
}
if (args.officialFirst && category && typeof args.isOfficial !== "boolean") {
return await listOfficialFirstPackageCategoryPage(ctx, {
...args,
@@ -3910,7 +3980,7 @@ async function listOfficialFirstPackageCategoryPage(
category: PluginCategorySlug;
topic?: string;
excludedScanStatuses?: PackageListScanStatus[];
sort?: "updated" | "downloads" | "recommended" | "installs";
sort?: "updated" | "downloads" | "recommended" | "installs" | "trending";
viewerUserId?: Id<"users">;
paginationOpts: { cursor: string | null; numItems: number };
},
@@ -4420,7 +4490,11 @@ export const processPackageStatEventsInternal = internalMutation({
};
await ctx.db.patch(pkg._id, {
stats: nextStats,
...computePackageRecommendationPatch(nextStats),
...computePackageRecommendationPatch(nextStats, {
createdAt: pkg.createdAt ?? pkg._creationTime,
updatedAt: pkg.updatedAt,
now,
}),
});
packagesUpdated += 1;
}
@@ -7827,12 +7901,19 @@ export const reservePackageNameInternal = internalMutation({
isOfficial: false,
tags: {},
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
...computePackageRecommendationPatch({
downloads: 0,
installs: 0,
stars: 0,
versions: 0,
}),
...computePackageRecommendationPatch(
{
downloads: 0,
installs: 0,
stars: 0,
versions: 0,
},
{
createdAt: now,
updatedAt: now,
now,
},
),
createdAt: now,
updatedAt: now,
});
@@ -8902,12 +8983,19 @@ export const insertReleaseInternal = internalMutation({
verification: args.verification,
scanStatus: args.verification?.scanStatus,
stats: { downloads: 0, installs: 0, stars: 0, versions: 0 },
...computePackageRecommendationPatch({
downloads: 0,
installs: 0,
stars: 0,
versions: 0,
}),
...computePackageRecommendationPatch(
{
downloads: 0,
installs: 0,
stars: 0,
versions: 0,
},
{
createdAt: now,
updatedAt: now,
now,
},
),
createdAt: now,
updatedAt: now,
}));
+19 -1
View File
@@ -1806,7 +1806,24 @@ const packageDailyStats = defineTable({
downloads: v.number(),
installs: v.number(),
updatedAt: v.number(),
}).index("by_package_day", ["packageId", "day"]);
})
.index("by_package_day", ["packageId", "day"])
.index("by_day", ["day"]);
const packageLeaderboards = defineTable({
kind: v.string(),
generatedAt: v.number(),
rangeStartDay: v.number(),
rangeEndDay: v.number(),
items: v.array(
v.object({
packageId: v.id("packages"),
score: v.number(),
installs: v.number(),
downloads: v.number(),
}),
),
}).index("by_kind", ["kind", "generatedAt"]);
const packageTrustedPublishers = defineTable({
packageId: v.id("packages"),
@@ -2914,6 +2931,7 @@ export default defineSchema({
skillCardGenerationJobs,
packageStatEvents,
packageDailyStats,
packageLeaderboards,
packageTrustedPublishers,
packagePublishTokens,
packagePublishUploadTickets,
+9 -1
View File
@@ -228,6 +228,14 @@ function comparePopularityStats(a: PopularityStats, b: PopularityStats) {
return b.stars - a.stars || (b.installsAllTime ?? 0) - (a.installsAllTime ?? 0);
}
function compareSkillTrustAndUsage(a: SkillSearchEntry, b: SkillSearchEntry) {
return (
Number(Boolean(b.owner?.official)) - Number(Boolean(a.owner?.official)) ||
comparePopularityStats(a.skill.stats, b.skill.stats) ||
(b.skill.stats.downloads ?? 0) - (a.skill.stats.downloads ?? 0)
);
}
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
if (fallback.length === 0) return primary;
const out = [...primary];
@@ -488,7 +496,7 @@ export const searchSkills: ReturnType<typeof action> = action({
(a, b) =>
a.rankTier - b.rankTier ||
b.score - a.score ||
comparePopularityStats(a.skill.stats, b.skill.stats) ||
compareSkillTrustAndUsage(a, b) ||
b.skill.updatedAt - a.skill.updatedAt,
)
.slice(0, limit);
+1 -1
View File
@@ -146,8 +146,8 @@ describe("skills.listPublicPageV4", () => {
expect(result.page.map((entry) => entry.skill.slug)).toEqual([
"downloads-skill",
"updated-skill",
"installs-skill",
"updated-skill",
"stars-skill",
]);
});
+27 -5
View File
@@ -5848,9 +5848,23 @@ export const listPublicTrendingPage = query({
args: {
limit: v.optional(v.number()),
nonSuspiciousOnly: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args) => {
const limit = clampInt(args.limit ?? 25, 1, MAX_PUBLIC_LIST_LIMIT);
const normalizedCategorySlug = args.categorySlug?.trim().toLowerCase();
const categorySlug =
args.categorySlug === undefined
? undefined
: normalizedCategorySlug && isSkillCategorySlug(normalizedCategorySlug)
? normalizedCategorySlug
: null;
if (args.categorySlug !== undefined && categorySlug === null) {
return { items: [], nextCursor: null };
}
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return { items: [], nextCursor: null };
const kind = args.nonSuspiciousOnly
? TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND
: TRENDING_LEADERBOARD_KIND;
@@ -5870,6 +5884,8 @@ export const listPublicTrendingPage = query({
.unique();
if (!digest) continue;
if (args.nonSuspiciousOnly && digest.isSuspicious) continue;
if (categorySlug && !resolveStoredSkillCategories(digest).includes(categorySlug)) continue;
if (topic && !getCatalogTopicSlugs(digest.topics).includes(topic)) continue;
const item = await buildPublicSkillEntryFromDigest(ctx, digest);
if (!item) continue;
items.push(item);
@@ -6965,11 +6981,17 @@ function readDigestRecommendationScore(digest: Doc<"skillSearchDigest">): number
(digest.recommendedScoreVersion === RECOMMENDATION_SCORE_VERSION
? digest.recommendedScore
: undefined) ??
computeRecommendationScore({
downloads: readDigestRankStat(digest, "downloads"),
installs: readDigestRankStat(digest, "installsAllTime"),
stars: readDigestRankStat(digest, "stars"),
})
computeRecommendationScore(
{
downloads: readDigestRankStat(digest, "downloads"),
installs: readDigestRankStat(digest, "installsAllTime"),
stars: readDigestRankStat(digest, "stars"),
},
{
createdAt: digest.createdAt,
updatedAt: digest.updatedAt,
},
)
);
}
+22 -10
View File
@@ -408,19 +408,31 @@ function buildSkillStatPatch(skill: Doc<"skills">) {
}
function computeSkillDigestRecommendationScore(digest: Doc<"skillSearchDigest">) {
return computeRecommendationScore({
downloads: digest.statsDownloads ?? digest.stats.downloads,
installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0,
stars: digest.statsStars ?? digest.stats.stars,
});
return computeRecommendationScore(
{
downloads: digest.statsDownloads ?? digest.stats.downloads,
installs: digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? 0,
stars: digest.statsStars ?? digest.stats.stars,
},
{
createdAt: digest.createdAt,
updatedAt: digest.updatedAt,
},
);
}
function computePackageRecommendationScore(pkg: Doc<"packages">) {
return computeRecommendationScore({
downloads: pkg.stats.downloads,
installs: pkg.stats.installs,
stars: pkg.stats.stars,
});
return computeRecommendationScore(
{
downloads: pkg.stats.downloads,
installs: pkg.stats.installs,
stars: pkg.stats.stars,
},
{
createdAt: pkg.createdAt ?? pkg._creationTime,
updatedAt: pkg.updatedAt,
},
);
}
/**
+6 -2
View File
@@ -548,7 +548,7 @@ Query params:
- `family` (optional): `skill`, `code-plugin`, or `bundle-plugin`
- `channel` (optional): `official`, `community`, or `private`
- `isOfficial` (optional): `true` or `false`
- `sort` (optional): `updated` (default), `recommended`, `downloads`, legacy alias `installs`
- `sort` (optional): `updated` (default), `recommended`, `trending`, `downloads`, legacy alias `installs`
- `category` (optional): plugin category filter. Supported only when the
request is scoped to plugin packages (`/api/v1/plugins`,
`/api/v1/code-plugins`, `/api/v1/bundle-plugins`, or package endpoints with
@@ -598,7 +598,7 @@ Query params:
- `limit` (optional): integer (1-100)
- `cursor` (optional): pagination cursor
- `isOfficial` (optional): `true` or `false`
- `sort` (optional): `recommended` (default), `downloads`, `updated`, legacy alias `installs`
- `sort` (optional): `recommended` (default), `trending`, `downloads`, `updated`, legacy alias `installs`
- `category` (optional): plugin category filter. Current values:
`channels`, `models`, `memory`, `context`, `voice`, `media`, `web`,
`tools`, `runtime`, `gateway`, `security`, `other`.
@@ -609,6 +609,10 @@ Legacy v1 filter aliases remain accepted on read endpoints:
- `observability` and `deployment` resolve to `gateway`.
- `dev-tools` resolves to `runtime`.
`trending` is a seven-day install/download leaderboard and does not use all-time totals.
On the unified `/api/v1/packages` endpoint it is plugin-only; use
`/api/v1/skills?sort=trending` for the skill catalog.
Legacy aliases are not accepted as stored or author-declared category values.
### `GET /api/v1/skills/export`
+16
View File
@@ -27,3 +27,19 @@ only category or topic filter because limited global results can under-fill scop
stop at an explicit safety scan budget, but the result limit applies after scoped matches are found.
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.
## Browse Discovery Ranking
Browse defaults are a discovery surface, not a proxy for lifetime downloads. The materialized
recommendation score combines sublinear installs, downloads, and stars with a decaying freshness
signal and a bounded boost for newly published items. Download weight is intentionally lower than
install, star, and freshness signals so a large historical footprint cannot permanently occupy the
default page. Recommendation scores are refreshed by maintenance jobs because freshness changes even
when an item receives no new events.
Trending is a separate seven-day activity leaderboard built from daily install and download
aggregates. It must not be derived from all-time totals. Skills and plugins expose the same
trending concept; suspicious or unavailable items are filtered before public display.
Publisher diversity is a product follow-up for browse ranking. The current contract guarantees
freshness and bounded novelty, while preserving stable cursor pagination and trust filters.
+13 -13
View File
@@ -408,13 +408,13 @@ describe("plugins route", () => {
expect.objectContaining({
cursor: "cursor:current",
limit: 25,
sort: "downloads",
sort: "recommended",
}),
);
expect(fetchPluginCatalogMock.mock.calls[0]?.[0]).not.toHaveProperty("family");
});
it("uses downloads as the plugin browse ranking", async () => {
it("uses recommendation ranking as the plugin browse default", async () => {
fetchPluginCatalogMock.mockResolvedValue({ items: [], nextCursor: null });
const { loadPluginsPageData } = await import("../routes/plugins/index");
@@ -422,7 +422,7 @@ describe("plugins route", () => {
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
sort: "downloads",
sort: "recommended",
limit: 25,
}),
);
@@ -434,7 +434,7 @@ describe("plugins route", () => {
await loadPluginsPageData({
q: "security",
sort: "downloads",
sort: "recommended",
cursor: "cursor:search",
});
@@ -453,12 +453,12 @@ describe("plugins route", () => {
const { loadPluginsPageData } = await import("../routes/plugins/index");
await loadPluginsPageData({
sort: "downloads",
sort: "recommended",
});
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
sort: "downloads",
sort: "recommended",
limit: 25,
}),
);
@@ -578,7 +578,7 @@ describe("plugins route", () => {
expect.objectContaining({
category: "security",
cursor: "cursor:next",
sort: "downloads",
sort: "recommended",
}),
);
expect(navigateMock).not.toHaveBeenCalled();
@@ -927,7 +927,7 @@ describe("plugins route", () => {
expect(fetchPluginCatalogMock).toHaveBeenCalledWith(
expect.objectContaining({
isOfficial: true,
sort: "downloads",
sort: "recommended",
limit: 25,
}),
);
@@ -959,7 +959,7 @@ describe("plugins route", () => {
family: undefined,
cursor: undefined,
featured: true,
sort: "downloads",
sort: "recommended",
});
});
@@ -982,7 +982,7 @@ describe("plugins route", () => {
cursor: undefined,
family: undefined,
featured: undefined,
sort: "downloads",
sort: "recommended",
});
});
@@ -1318,7 +1318,7 @@ describe("plugins route", () => {
featured: true,
cursor: undefined,
family: undefined,
sort: "downloads",
sort: "recommended",
});
});
@@ -1339,7 +1339,7 @@ describe("plugins route", () => {
cursor: undefined,
family: undefined,
featured: undefined,
sort: "downloads",
sort: undefined,
});
});
@@ -1443,7 +1443,7 @@ describe("plugins route", () => {
const sortOptions = Array.from(
screen.getByRole("radiogroup", { name: "Sort order" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(sortOptions).toEqual(["All", "Verified", "Updated"]);
expect(sortOptions).toEqual(["All", "Trending", "Verified", "Updated"]);
expect(screen.queryByRole("radio", { name: "Most downloaded" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Newest" })).toBeNull();
expect(screen.queryByRole("radio", { name: "Name" })).toBeNull();
+2 -2
View File
@@ -87,7 +87,7 @@ describe("SkillsIndex", () => {
const sortOptions = Array.from(
screen.getByRole("radiogroup", { name: "Skill view" }).querySelectorAll('[role="radio"]'),
).map((option) => option.textContent);
expect(sortOptions).toEqual(["All", "Top", "Most starred", "Featured"]);
expect(sortOptions).toEqual(["All", "Trending", "Top", "Most starred", "Featured"]);
});
it("offers Top without exposing downloads as a browse view", async () => {
@@ -108,7 +108,7 @@ describe("SkillsIndex", () => {
fireEvent.click(screen.getByRole("combobox", { name: "Sort" }));
const sortOptions = screen.getAllByRole("option").map((option) => option.textContent);
expect(views).toEqual(["All", "Top", "Most starred", "Featured"]);
expect(views).toEqual(["All", "Trending", "Top", "Most starred", "Featured"]);
expect(sortOptions).toEqual(["Recently updated", "Newest", "Name"]);
});
+1 -1
View File
@@ -170,7 +170,7 @@ export type PackageVersionDetail = {
};
type PluginFamily = "code-plugin" | "bundle-plugin";
type PackageCatalogSort = "updated" | "recommended" | "downloads";
type PackageCatalogSort = "updated" | "recommended" | "downloads" | "trending";
type PluginCatalogResult = {
items: PackageListItem[];
+13 -13
View File
@@ -29,7 +29,7 @@ import {
} from "../../lib/packageApi";
import { useMediaQuery } from "../../lib/useMediaQuery";
type VisiblePluginSort = "recommended" | "updated" | "downloads";
type VisiblePluginSort = "recommended" | "updated" | "downloads" | "trending";
type PluginSort = VisiblePluginSort | "relevance";
type LegacyPluginSort = PluginSort | "newest" | "name" | "installs";
type PluginBrowseTab = VisiblePluginSort | "official";
@@ -52,7 +52,8 @@ type PluginView = "list" | "grid";
type LegacyPluginView = PluginView | "cards";
const PLUGIN_BROWSE_TABS = [
{ value: "downloads", label: "All" },
{ value: "recommended", label: "All" },
{ value: "trending", label: "Trending" },
{
value: "official",
label: "Verified",
@@ -115,6 +116,7 @@ function parsePluginSort(value: unknown): LegacyPluginSort | undefined {
value === "relevance" ||
value === "updated" ||
value === "downloads" ||
value === "trending" ||
value === "installs" ||
value === "newest" ||
value === "name"
@@ -151,7 +153,7 @@ function normalizeActivePluginSort(sort: LegacyPluginSort | undefined): PluginSo
function getDefaultPluginBrowseSort(
_args: Pick<PluginsPageDataRequest, "category" | "featured" | "official">,
): VisiblePluginSort {
return "downloads";
return "recommended";
}
function hasPersistentPluginBrowseFilter(
@@ -179,6 +181,7 @@ export async function loadPluginsPageData(
...(!args.q &&
(args.sort === "downloads" ||
args.sort === "updated" ||
args.sort === "trending" ||
!args.sort ||
args.sort === "recommended")
? { sort: args.sort ?? getDefaultPluginBrowseSort(args) }
@@ -271,6 +274,7 @@ export const Route = createFileRoute("/plugins/")({
search.sort !== "recommended" &&
search.sort !== "updated" &&
search.sort !== "downloads" &&
search.sort !== "trending" &&
!(hasQuery && search.sort === "relevance");
const staleFeatured = Boolean(hasQuery && search.featured);
if (incompatibleSort || staleFeatured) {
@@ -300,7 +304,7 @@ function PluginsIndexPending() {
<BrowseTabs
ariaLabel="Sort order"
options={PLUGIN_BROWSE_TABS}
value="downloads"
value="recommended"
onChange={() => {}}
/>
<BrowseActions>
@@ -430,11 +434,7 @@ function PluginsIndex() {
: search.sort === "relevance" || search.sort === "newest" || search.sort === "name"
? "recommended"
: (search.sort ?? (hasQuery ? "recommended" : getDefaultPluginBrowseSort(search)));
const activeBrowseTab: PluginBrowseTab = search.official
? "official"
: activeSort === "recommended"
? "downloads"
: activeSort;
const activeBrowseTab: PluginBrowseTab = search.official ? "official" : activeSort;
const visibleItems = useMemo(() => {
return hasQuery ? sortPluginSearchItems(items, activeSort) : items;
}, [activeSort, hasQuery, items]);
@@ -453,11 +453,11 @@ function PluginsIndex() {
return;
}
handleSortChange(value ?? "downloads");
handleSortChange(value ?? "recommended");
};
const handleSortChange = (value: string) => {
const nextSort = parsePluginSort(value) ?? "downloads";
const nextSort = parsePluginSort(value) ?? "recommended";
void navigate({
search: (prev: PluginSearchState) => {
@@ -466,8 +466,8 @@ function PluginsIndex() {
const sort =
isExplicitFilteredRecommendation || nextSort === "downloads"
? nextSort
: nextSort === "updated"
? "updated"
: nextSort === "updated" || nextSort === "trending"
? nextSort
: undefined;
const nextSearch: PluginSearchState = {
...prev,
+5 -2
View File
@@ -7,10 +7,11 @@ export const sortKeys = [
"stars",
"name",
"updated",
"trending",
] as const;
export type SortKey = (typeof sortKeys)[number];
export type ListSortKey = Exclude<SortKey, "relevance" | "recommended" | "default">;
export type ListSortKey = Exclude<SortKey, "relevance" | "recommended" | "default" | "trending">;
export type SortDir = "asc" | "desc";
export function parseSort(value: unknown): SortKey {
@@ -27,5 +28,7 @@ export function parseDir(value: unknown, sort: SortKey): SortDir {
}
export function toListSort(sort: SortKey): ListSortKey | undefined {
return sort === "relevance" || sort === "recommended" || sort === "default" ? undefined : sort;
return sort === "relevance" || sort === "recommended" || sort === "default" || sort === "trending"
? undefined
: sort;
}
+16 -1
View File
@@ -87,7 +87,7 @@ export function useSkillsBrowseModel({
: requestedSort === "recommended" && hasQuery
? "relevance"
: (requestedSort ?? (hasQuery ? "relevance" : "recommended"));
const listSort = toListSort(sort);
const listSort = sort === "trending" ? undefined : toListSort(sort);
const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort);
const searchKey = hasQuery
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}`
@@ -105,6 +105,20 @@ export function useSkillsBrowseModel({
let pageCursor = cursor;
let consecutiveEmptyPages = 0;
try {
if (sort === "trending") {
const result = await convexHttp.query(api.skills.listPublicTrendingPage, {
limit: pageSize,
nonSuspiciousOnly: true,
categorySlug: activeCategory?.slug,
topic: activeTopic,
});
if (generation !== fetchGeneration.current) return;
setListResults(result.items);
setListCursor(null);
setListAutoLoadPaused(false);
setListStatus("done");
return;
}
while (true) {
const result = await convexHttp.query(api.skills.listPublicPageV4, {
cursor: pageCursor ?? undefined,
@@ -158,6 +172,7 @@ export function useSkillsBrowseModel({
excludeCategoryKeywords,
featuredOnly,
listSort,
sort,
],
);
+17 -5
View File
@@ -30,6 +30,7 @@ import {
const SKILLS_VIEW_OPTIONS = [
{ value: "all", label: "All" },
{ value: "trending", label: "Trending" },
{ value: "top", label: "Top" },
{ value: "stars", label: "Most starred" },
{ value: "featured", label: "Featured" },
@@ -86,11 +87,13 @@ export function SkillsIndex() {
const activeView = model.featuredOnly
? "featured"
: model.sort === "downloads"
? "top"
: model.sort === "stars"
? "stars"
: "all";
: model.sort === "trending"
? "trending"
: model.sort === "downloads"
? "top"
: model.sort === "stars"
? "stars"
: "all";
const activeSort = ["updated", "newest", "name"].includes(model.sort) ? model.sort : undefined;
const hasActiveFilters = model.hasQuery || Boolean(model.activeCategory) || model.featuredOnly;
const totalSkillsCount = useQuery(api.skills.countPublicSkills, {});
@@ -109,6 +112,15 @@ export function SkillsIndex() {
(value: string) => {
void navigate({
search: (prev: SkillsSearchState) => {
if (value === "trending") {
return {
...prev,
sort: "trending",
dir: "desc",
featured: undefined,
highlighted: undefined,
};
}
if (value === "top" || value === "stars") {
const sort = value === "top" ? "downloads" : "stars";
return {