From 65ea02f4caa19f6c3f240ca0a534240af876fbe7 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Sat, 25 Jul 2026 02:06:41 -0500 Subject: [PATCH] feat: add canonical mixed skill search (#3264) * feat: add canonical mixed skill search * test: add permanent Test search proof --- .github/workflows/deploy-test.yml | 182 ++++- convex/_generated/api.d.ts | 8 + convex/devSeed.localFixtures.test.ts | 24 + convex/devSeed.ts | 105 +++ convex/httpApi.handlers.test.ts | 25 + convex/httpApi.ts | 27 +- convex/httpApiV1.handlers.test.ts | 45 ++ convex/httpApiV1/skillsV1.ts | 56 +- convex/lib/canonicalSkillSearch.test.ts | 140 ++++ convex/lib/canonicalSkillSearch.ts | 113 +++ convex/lib/canonicalSkillSearchBounds.ts | 9 + convex/lib/canonicalSkillSearchResponse.ts | 75 ++ convex/schema.ts | 39 +- convex/search.test.ts | 621 ++++++++++++++-- convex/search.ts | 684 ++++++++++++++++-- convex/searchTestFixtures.test.ts | 236 ++++++ convex/searchTestFixtures.ts | 261 +++++++ convex/skillsShMirror.test.ts | 2 + convex/skillsShMirror.ts | 7 + package.json | 1 + .../clawhub/src/cli/commands/skills.test.ts | 46 ++ packages/clawhub/src/cli/commands/skills.ts | 33 +- packages/clawhub/src/schema/schemas.test.ts | 64 ++ packages/clawhub/src/schema/schemas.ts | 42 ++ .../canonical-search/proof-contract.test.ts | 98 +++ scripts/canonical-search/proof-contract.ts | 78 ++ scripts/canonical-search/prove-test.ts | 168 +++++ specs/github-backed-skills.md | 15 +- specs/search-relevance.md | 15 +- specs/spec.md | 5 +- src/__tests__/deploy-test-workflow.test.ts | 47 ++ src/__tests__/home-listing-section.test.tsx | 8 +- src/__tests__/skills-index.test.tsx | 189 +++-- src/components/HomeListingSection.tsx | 2 +- src/lib/useUnifiedSearch.ts | 2 +- src/routes/search.tsx | 2 +- src/routes/skills/-SkillsResults.tsx | 99 ++- src/routes/skills/-types.ts | 70 +- src/routes/skills/-useSkillsBrowseModel.ts | 70 +- 39 files changed, 3402 insertions(+), 311 deletions(-) create mode 100644 convex/lib/canonicalSkillSearch.test.ts create mode 100644 convex/lib/canonicalSkillSearch.ts create mode 100644 convex/lib/canonicalSkillSearchBounds.ts create mode 100644 convex/lib/canonicalSkillSearchResponse.ts create mode 100644 convex/searchTestFixtures.test.ts create mode 100644 convex/searchTestFixtures.ts create mode 100644 scripts/canonical-search/proof-contract.test.ts create mode 100644 scripts/canonical-search/proof-contract.ts create mode 100644 scripts/canonical-search/prove-test.ts diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml index f5144656..4048045c 100644 --- a/.github/workflows/deploy-test.yml +++ b/.github/workflows/deploy-test.yml @@ -33,14 +33,18 @@ jobs: ((github.ref == 'refs/heads/pe/claw-563-skills-sh-mirror-10k' && inputs.branch_test_confirm == 'deploy-claw-563-to-permanent-test') || (github.ref == 'refs/heads/pe/claw-589-trending-rank-overlay' && - inputs.branch_test_confirm == 'deploy-claw-589-to-permanent-test')) && + inputs.branch_test_confirm == 'deploy-claw-589-to-permanent-test') || + (github.ref == 'refs/heads/pe/claw-577-canonical-mixed-search' && + inputs.branch_test_confirm == 'deploy-claw-577-to-permanent-test')) && github.actor == 'Patrick-Erichsen' && inputs.expected_sha != '')) || (github.event_name == 'pull_request' && ((github.event.pull_request.head.ref == 'pe/claw-563-skills-sh-mirror-10k' && contains(github.event.pull_request.labels.*.name, 'test-mirror-load')) || (github.event.pull_request.head.ref == 'pe/claw-589-trending-rank-overlay' && - contains(github.event.pull_request.labels.*.name, 'test-trending-load'))) && + contains(github.event.pull_request.labels.*.name, 'test-trending-load')) || + (github.event.pull_request.head.ref == 'pe/claw-577-canonical-mixed-search' && + contains(github.event.pull_request.labels.*.name, 'test-search-load'))) && github.event.pull_request.head.repo.full_name == github.repository && github.actor == 'Patrick-Erichsen' && github.event.pull_request.head.sha != '') || @@ -92,6 +96,14 @@ jobs: then branch_test_allowed=true fi + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]] && + [[ "$GITHUB_REF" == refs/heads/pe/claw-577-canonical-mixed-search ]] && + [[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] && + [[ "${{ inputs.branch_test_confirm }}" == deploy-claw-577-to-permanent-test ]] && + [[ "${{ inputs.expected_sha }}" == "$deploy_sha" ]] + then + branch_test_allowed=true + fi if [[ "$GITHUB_EVENT_NAME" == pull_request ]] && [[ "$GITHUB_HEAD_REF" == pe/claw-563-skills-sh-mirror-10k ]] && [[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] && @@ -108,6 +120,14 @@ jobs: then branch_test_allowed=true fi + if [[ "$GITHUB_EVENT_NAME" == pull_request ]] && + [[ "$GITHUB_HEAD_REF" == pe/claw-577-canonical-mixed-search ]] && + [[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] && + [[ "${{ github.event.pull_request.head.repo.full_name }}" == "$GITHUB_REPOSITORY" ]] && + [[ "${{ github.event.pull_request.head.sha }}" == "$deploy_sha" ]] + then + branch_test_allowed=true + fi if [[ "$branch_test_allowed" != true ]]; then echo "::error::Refusing non-main Test deploy without an exact approved branch guard" exit 1 @@ -680,3 +700,161 @@ jobs: claw589-cleanup.json claw589-deployment.json claw589-discarded-run.json + + claw577-search-proof: + needs: deploy-test + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.head.ref == 'pe/claw-577-canonical-mixed-search' && + contains(github.event.pull_request.labels.*.name, 'test-search-load')) || + (github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/pe/claw-577-canonical-mixed-search' && + inputs.branch_test_confirm == 'deploy-claw-577-to-permanent-test' && + inputs.expected_sha == needs.deploy-test.outputs.deploy_sha) + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: + name: Test + steps: + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + ref: >- + ${{ + github.event_name == 'pull_request' && github.event.pull_request.head.sha || + inputs.expected_sha + }} + + - uses: ./.github/actions/setup-bun + + - name: Prove canonical search order and cost in permanent Test + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} + DEPLOY_SHA: ${{ needs.deploy-test.outputs.deploy_sha }} + TEST_SITE_URL: ${{ vars.SITE_URL }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} + run: | + set -euo pipefail + confirm=manage-claw-577-canonical-search-test-fixture + mkdir -p proof/claw-577 + printf '{}\n' > proof/claw-577/active-fixture.json + cleanup() { + set +e + digest_id="$(jq -r '.digestId // empty' proof/claw-577/active-fixture.json 2>/dev/null)" + run_id="$(jq -r '.runId // empty' proof/claw-577/active-fixture.json 2>/dev/null)" + recovery_exit=0 + cleanup_exit=0 + if [[ -z "$digest_id" || -z "$run_id" ]]; then + bunx convex run --no-push \ + searchTestFixtures:readCanonicalSearchTestFixture \ + "{\"confirm\":\"$confirm\"}" > claw577-cleanup-recovery.json 2>&1 + recovery_exit=$? + if [[ "$recovery_exit" -eq 0 ]]; then + digest_id="$(jq -r '.digestId // empty' claw577-cleanup-recovery.json)" + run_id="$(jq -r '.runId // empty' claw577-cleanup-recovery.json)" + fi + else + jq -n '{skipped:true,reason:"active fixture IDs were recorded"}' \ + > claw577-cleanup-recovery.json + fi + if [[ -n "$digest_id" && -n "$run_id" ]]; then + cleanup_args="$( + jq -cn \ + --arg confirm "$confirm" \ + --arg digestId "$digest_id" \ + --arg runId "$run_id" \ + '{confirm:$confirm,digestId:$digestId,runId:$runId}' + )" + bunx convex run --no-push \ + searchTestFixtures:cleanupCanonicalSearchTestFixture \ + "$cleanup_args" > claw577-cleanup.json 2>&1 + cleanup_exit=$? + else + jq -n '{ok:true,removed:false,skipped:true}' > claw577-cleanup.json + if [[ "$recovery_exit" -ne 0 ]]; then + cleanup_exit="$recovery_exit" + fi + fi + bunx convex run --no-push \ + searchTestFixtures:readCanonicalSearchTestFixture \ + "{\"confirm\":\"$confirm\"}" > claw577-cleanup-readback.json 2>&1 + readback_exit=$? + jq -n \ + --argjson cleanupExit "$cleanup_exit" \ + --argjson recoveryExit "$recovery_exit" \ + --argjson readbackExit "$readback_exit" \ + '{cleanupExit:$cleanupExit,recoveryExit:$recoveryExit,readbackExit:$readbackExit}' \ + > claw577-cleanup-status.json + set -e + [[ "$cleanup_exit" -eq 0 && "$recovery_exit" -eq 0 && "$readback_exit" -eq 0 ]] + jq -e '.present == false' claw577-cleanup-readback.json >/dev/null + } + trap cleanup EXIT + + [[ "$(git rev-parse HEAD)" == "$DEPLOY_SHA" ]] + bunx convex run --no-push appMeta:getDeploymentInfo '{}' > claw577-deployment-readback.json + jq -e --arg sha "$DEPLOY_SHA" '.appBuildSha == $sha' claw577-deployment-readback.json + bunx convex run --no-push \ + searchTestFixtures:readCanonicalSearchTestFixture \ + "{\"confirm\":\"$confirm\"}" > claw577-fixture-before.json + jq -e '.present == false' claw577-fixture-before.json >/dev/null + + bunx convex run --no-push \ + searchTestFixtures:seedCanonicalSearchTestFixture \ + "{\"confirm\":\"$confirm\"}" > proof/claw-577/active-fixture.json + jq -e ' + .ok == true and + .created == true and + (.digestId | type == "string") and + (.runId | type == "string") + ' proof/claw-577/active-fixture.json >/dev/null + + bun run search:prove-test > claw577-proof-output.json + jq -e --arg sha "$DEPLOY_SHA" ' + .target.environment == "permanent Test" and + .target.deploySha == $sha and + .target.productionWrites == 0 and + .target.schedulesCreated == 0 and + .target.scansPlanned == 0 and + .target.scansAdmitted == 0 and + .target.claimsCreated == 0 and + .fixture.lifetimeInstalls == 9000000 and + .fixture.rankingWeight == 0 and + .contract.samplesPerSurface >= 3 and + (.cases | length) == 4 + ' proof/claw-577/canonical-search-test-proof.json >/dev/null + + jq -n \ + --arg sourceSha "$DEPLOY_SHA" \ + --arg deploymentUrl "$TEST_SITE_URL" \ + '{ + sourceSha:$sourceSha, + deploymentUrl:$deploymentUrl, + convexDeployment:"academic-chihuahua-392" + }' > claw577-deployment.json + + cleanup + trap - EXIT + jq -e ' + .cleanupExit == 0 and + .recoveryExit == 0 and + .readbackExit == 0 + ' claw577-cleanup-status.json >/dev/null + + - name: Upload permanent Test canonical search proof + if: always() + uses: actions/upload-artifact@v7 + with: + name: claw577-search-proof + if-no-files-found: error + path: | + proof/claw-577/canonical-search-test-proof.json + proof/claw-577/active-fixture.json + claw577-proof-output.json + claw577-fixture-before.json + claw577-cleanup.json + claw577-cleanup-recovery.json + claw577-cleanup-readback.json + claw577-cleanup-status.json + claw577-deployment-readback.json + claw577-deployment.json diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0ddfbc2b..a020c674 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -57,6 +57,9 @@ import type * as lib_artifactModeration from "../lib/artifactModeration.js"; import type * as lib_artifactText from "../lib/artifactText.js"; import type * as lib_badges from "../lib/badges.js"; import type * as lib_batching from "../lib/batching.js"; +import type * as lib_canonicalSkillSearch from "../lib/canonicalSkillSearch.js"; +import type * as lib_canonicalSkillSearchBounds from "../lib/canonicalSkillSearchBounds.js"; +import type * as lib_canonicalSkillSearchResponse from "../lib/canonicalSkillSearchResponse.js"; import type * as lib_catalogClassification from "../lib/catalogClassification.js"; import type * as lib_catalogClassifier from "../lib/catalogClassifier.js"; import type * as lib_changelog from "../lib/changelog.js"; @@ -168,6 +171,7 @@ import type * as rateLimits from "../rateLimits.js"; import type * as retention from "../retention.js"; import type * as rolloutCapabilities from "../rolloutCapabilities.js"; import type * as search from "../search.js"; +import type * as searchTestFixtures from "../searchTestFixtures.js"; import type * as securityDataset from "../securityDataset.js"; import type * as securityDatasetNode from "../securityDatasetNode.js"; import type * as securityScan from "../securityScan.js"; @@ -247,6 +251,9 @@ declare const fullApi: ApiFromModules<{ "lib/artifactText": typeof lib_artifactText; "lib/badges": typeof lib_badges; "lib/batching": typeof lib_batching; + "lib/canonicalSkillSearch": typeof lib_canonicalSkillSearch; + "lib/canonicalSkillSearchBounds": typeof lib_canonicalSkillSearchBounds; + "lib/canonicalSkillSearchResponse": typeof lib_canonicalSkillSearchResponse; "lib/catalogClassification": typeof lib_catalogClassification; "lib/catalogClassifier": typeof lib_catalogClassifier; "lib/changelog": typeof lib_changelog; @@ -358,6 +365,7 @@ declare const fullApi: ApiFromModules<{ retention: typeof retention; rolloutCapabilities: typeof rolloutCapabilities; search: typeof search; + searchTestFixtures: typeof searchTestFixtures; securityDataset: typeof securityDataset; securityDatasetNode: typeof securityDatasetNode; securityScan: typeof securityScan; diff --git a/convex/devSeed.localFixtures.test.ts b/convex/devSeed.localFixtures.test.ts index 7ad97a28..60b0e529 100644 --- a/convex/devSeed.localFixtures.test.ts +++ b/convex/devSeed.localFixtures.test.ts @@ -5,6 +5,7 @@ import { currentUserSeedPackageName, currentUserSeedSkillSlug, seedCatalogPresentationFixtures, + seedCanonicalSearchFixture, seedFeaturedPluginPackagesMutation, seedGitHubBackedSkillSourceMutation, seedLocalFixtures, @@ -27,6 +28,9 @@ const seedFeaturedPluginPackagesHandler = ( const seedCatalogPresentationFixturesHandler = ( seedCatalogPresentationFixtures as unknown as WrappedHandler> )._handler; +const seedCanonicalSearchFixtureHandler = ( + seedCanonicalSearchFixture as unknown as WrappedHandler> +)._handler; const seedGitHubBackedSkillSourceHandler = ( seedGitHubBackedSkillSourceMutation as unknown as WrappedHandler> )._handler; @@ -172,6 +176,26 @@ function seedSkillArgs(storageId: string) { } describe("devSeed local fixtures", () => { + it("idempotently seeds an activated external row for local canonical search proof", async () => { + const { db, tables } = createDb(); + + await seedCanonicalSearchFixtureHandler(createMutationCtx(db) as never, {}); + await seedCanonicalSearchFixtureHandler(createMutationCtx(db) as never, {}); + + expect(tables.skillsShMirrorRuns).toHaveLength(1); + expect(tables.skillsShMirrorDigests).toHaveLength(1); + expect(tables.skillsShMirrorDigests?.[0]).toEqual( + expect.objectContaining({ + externalId: "acme/skills/risk-auditor", + searchSummary: "Audit agent workflows for security and operational risk.", + active: true, + publicVisible: true, + installable: true, + sourceFreshnessStatus: "observed-only", + }), + ); + }); + it("does not preconfigure GitHub-backed source fixtures in the local seed action", async () => { const mutationCalls: Array<{ args: Record }> = []; const deletedStorageIds: string[] = []; diff --git a/convex/devSeed.ts b/convex/devSeed.ts index cd919c69..97c7a651 100644 --- a/convex/devSeed.ts +++ b/convex/devSeed.ts @@ -911,6 +911,111 @@ export const seedTestFixtures: ReturnType = internalActio }, }); +const LOCAL_CANONICAL_SEARCH_EXTERNAL_ID = "acme/skills/risk-auditor"; + +/** Explicit local proof fixture; intentionally not part of shared Test seeding. */ +export const seedCanonicalSearchFixture = internalMutation({ + args: {}, + handler: async (ctx) => { + const now = Date.now(); + const existing = await ctx.db + .query("skillsShMirrorDigests") + .withIndex("by_external_id", (q) => q.eq("externalId", LOCAL_CANONICAL_SEARCH_EXTERNAL_ID)) + .unique(); + const runId = + existing?.lastObservedRunId ?? + (await ctx.db.insert("skillsShMirrorRuns", { + snapshotId: "local-canonical-search-v1", + status: "completed", + sourceTotal: 1, + sourcePageSize: 1, + sourceMeasuredAt: new Date(now).toISOString(), + page: 1, + offset: 0, + counts: { + observed: 1, + inserted: 1, + updated: 0, + unchanged: 0, + rejected: 0, + quarantined: 0, + quarantinedPreserved: 0, + conflicts: 0, + detailsInserted: 0, + detailsUpdated: 0, + detailsUnchanged: 0, + detailsMissing: 1, + detailsTruncated: 0, + tombstoned: 0, + reactivated: 0, + scansPlanned: 0, + scansAdmitted: 0, + }, + operations: { + functionCalls: 1, + dbReads: 1, + dbWrites: 2, + sourceRequests: 0, + sourceBytes: 0, + }, + actor: "local-dev-seed", + reason: "Reusable local canonical mixed-search browser proof fixture.", + startedAt: now, + completedAt: now, + updatedAt: now, + })); + const digest = { + externalId: LOCAL_CANONICAL_SEARCH_EXTERNAL_ID, + sourceType: "github" as const, + upstreamSourceType: "github", + owner: "acme", + repo: "skills", + slug: "risk-auditor", + normalizedSlug: "risk auditor", + normalizedSlugFirstToken: "risk", + displayName: "Risk Auditor", + normalizedDisplayName: "risk auditor", + normalizedDisplayNameFirstToken: "risk", + searchSummary: "Audit agent workflows for security and operational risk.", + searchText: + "Risk Auditor risk-auditor acme skills security risk-management security-audit Audit agent workflows for security and operational risk.", + sourceUrl: "https://skills.sh/acme/skills/risk-auditor", + canonicalRepoUrl: "https://github.com/acme/skills", + githubPath: "skills/risk-auditor", + githubCommit: "0000000000000000000000000000000000000000", + upstreamInstalls: 9_000_000, + upstreamScanners: { + genAgentTrustHub: { status: "unavailable" }, + socket: { status: "unavailable" }, + snyk: { status: "unavailable" }, + }, + inferredCategories: ["security"], + inferredTopics: ["risk-management", "security-audit"], + sourceFreshnessStatus: "observed-only" as const, + detailStatus: "missing" as const, + observationFingerprint: "local-canonical-search-v1", + sourceSnapshotId: "local-canonical-search-v1", + lastObservedRunId: runId, + active: true, + publicVisible: true, + installable: true, + firstObservedAt: existing?.firstObservedAt ?? now, + lastObservedAt: now, + updatedAt: now, + }; + + if (existing) { + await ctx.db.patch(existing._id, digest); + return { ok: true as const, digestId: existing._id }; + } + const digestId = await ctx.db.insert("skillsShMirrorDigests", { + ...digest, + createdAt: now, + }); + return { ok: true as const, digestId }; + }, +}); + export const backfillExistingPublicCorpusBatchRows = internalMutation({ args: { rows: v.array(publicCorpusSeedRowValidator), diff --git a/convex/httpApi.handlers.test.ts b/convex/httpApi.handlers.test.ts index 341447af..c37b92e1 100644 --- a/convex/httpApi.handlers.test.ts +++ b/convex/httpApi.handlers.test.ts @@ -92,6 +92,31 @@ describe("httpApi handlers", () => { }); }); + it("searchSkillsHttp preserves canonical mixed action shape and order", async () => { + const ordered = [ + { + id: "skills-sh:acme/skills/calendar", + source: "skills-sh", + slug: "calendar", + canonicalUrl: "/skills-sh/acme/skills/calendar", + }, + { + id: "clawhub:skills:calendar", + source: "clawhub", + slug: "calendar-native", + canonicalUrl: "/openclaw/skills/calendar-native", + }, + ]; + const response = await __handlers.searchSkillsHandler( + makeCtx({ runAction: vi.fn().mockResolvedValue(ordered) }), + new Request("https://example.com/api/search?q=calendar"), + ); + + await expect(response.json()).resolves.toEqual({ + results: [ordered[0], { ...ordered[1], owner: null }], + }); + }); + it("searchSkillsHttp omits highlightedOnly when approvedOnly is false", async () => { const runAction = vi.fn().mockResolvedValue([]); await __handlers.searchSkillsHandler( diff --git a/convex/httpApi.ts b/convex/httpApi.ts index d52177d7..a4791ad6 100644 --- a/convex/httpApi.ts +++ b/convex/httpApi.ts @@ -12,23 +12,12 @@ import type { ActionCtx } from "./_generated/server"; import { httpAction } from "./functions"; import { ambiguousSkillSlugMessage } from "./httpApiV1/shared"; import { requireApiTokenUser, requirePackagePublishAuth } from "./lib/apiTokenAuth"; +import { serializeCanonicalSkillSearchResults } from "./lib/canonicalSkillSearchResponse"; import { corsHeaders, mergeHeaders } from "./lib/httpHeaders"; import { applyRateLimit } from "./lib/httpRateLimit"; import { parseBooleanQueryParam, resolveBooleanQueryParam } from "./lib/httpUtils"; import { publishVersionForUser } from "./skills"; -type SearchSkillEntry = { - score: number; - skill: { - slug?: string; - displayName?: string; - summary?: string | null; - updatedAt?: number; - } | null; - ownerHandle?: string | null; - version: { version?: string } | null; -}; - const LEGACY_TELEMETRY_BATCH_SIZE = 100; const MAX_LEGACY_TELEMETRY_SKILLS = 5_000; @@ -73,19 +62,9 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) { limit, highlightedOnly: highlightedOnly || undefined, nonSuspiciousOnly: nonSuspiciousOnly || undefined, - })) as SearchSkillEntry[]; + })) as unknown[]; - return json({ - results: results.map((result) => ({ - score: result.score, - slug: result.skill?.slug, - ownerHandle: result.ownerHandle ?? null, - displayName: result.skill?.displayName, - summary: result.skill?.summary ?? null, - version: result.version?.version ?? null, - updatedAt: result.skill?.updatedAt, - })), - }); + return json({ results: serializeCanonicalSkillSearchResults(results) }); } export const searchSkillsHttp = httpAction(searchSkillsHandler); diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 83d18ab2..69def951 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -1963,6 +1963,51 @@ describe("httpApiV1 handlers", () => { }); }); + it("search preserves canonical mixed result shape and action order", async () => { + const ordered = [ + { + id: "clawhub:skills:exact", + source: "clawhub", + slug: "exact", + score: 6_110, + canonicalUrl: "/openclaw/skills/exact", + publisher: { + handle: "openclaw", + displayName: "OpenClaw", + image: "https://example.com/avatar.png", + official: true, + }, + }, + { + id: "skills-sh:vercel-labs/skills/find-skills", + source: "skills-sh", + slug: "find-skills", + score: 5_095, + canonicalUrl: "/skills-sh/vercel-labs/skills/find-skills", + }, + ]; + const runAction = vi.fn().mockResolvedValue(ordered); + const response = await __handlers.searchSkillsV1Handler( + makeCtx({ runAction, runMutation: vi.fn().mockResolvedValue(okRate()) }), + new Request("https://example.com/api/v1/search?q=find"), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + results: [ + { + ...ordered[0], + owner: { + handle: "openclaw", + displayName: "OpenClaw", + image: "https://example.com/avatar.png", + }, + }, + ordered[1], + ], + }); + }); + it("search forwards nonSuspiciousOnly", async () => { const runAction = vi.fn().mockResolvedValue([]); const runMutation = vi.fn().mockResolvedValue(okRate()); diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 333f3471..3c608052 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -20,6 +20,7 @@ import { api, internal } from "../_generated/api"; import type { Doc, Id } from "../_generated/dataModel"; import type { ActionCtx } from "../_generated/server"; import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth"; +import { serializeCanonicalSkillSearchResults } from "../lib/canonicalSkillSearchResponse"; import { buildGitHubSkillHandoffDescriptor, getGitHubHandoffBlock, @@ -88,28 +89,6 @@ const DEFAULT_EXPORT_PAGE_LIMIT = 250; const MAX_EXPORT_TOTAL_BYTES = 256 * 1024 * 1024; const MAX_SECURITY_VERDICT_ITEMS = 100; -type SearchSkillEntry = { - score: number; - skill: { - slug?: string; - displayName?: string; - summary?: string | null; - updatedAt?: number; - stats: { - downloads?: number; - stars?: number; - installs?: number; - }; - } | null; - version: { version?: string; createdAt?: number } | null; - ownerHandle?: string | null; - owner?: { - handle?: string | null; - displayName?: string | null; - image?: string | null; - } | null; -}; - type ListSkillsResult = { items: Array<{ skill: { @@ -1374,36 +1353,11 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { limit, highlightedOnly: highlightedOnly || undefined, nonSuspiciousOnly: nonSuspiciousOnly || undefined, - })) as SearchSkillEntry[]; + })) as unknown[]; - return json( - { - results: results.map((result) => { - const owner = result.owner - ? { - handle: result.owner.handle ?? null, - displayName: result.owner.displayName ?? null, - image: result.owner.image ?? null, - } - : null; - return { - score: result.score, - slug: result.skill?.slug, - displayName: result.skill?.displayName, - summary: result.skill?.summary ?? null, - version: result.version?.version ?? null, - // searchSkills already returns the ordinary public skill shape, including - // the combined presentation value and no source-attribution fields. - downloads: result.skill?.stats.downloads ?? 0, - updatedAt: result.skill?.updatedAt, - ownerHandle: result.ownerHandle ?? owner?.handle ?? null, - owner, - }; - }), - }, - 200, - rate.headers, - ); + // The action owns the canonical shape and ordering for every consumer. + // This HTTP surface must serialize it without projecting or re-sorting. + return json({ results: serializeCanonicalSkillSearchResults(results) }, 200, rate.headers); } export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Request) { diff --git a/convex/lib/canonicalSkillSearch.test.ts b/convex/lib/canonicalSkillSearch.test.ts new file mode 100644 index 00000000..02a5fb03 --- /dev/null +++ b/convex/lib/canonicalSkillSearch.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { + classifyCanonicalSkillSearchMatch, + compareCanonicalSkillSearchCandidates, + type CanonicalSkillSearchCandidate, +} from "./canonicalSkillSearch"; + +function candidate( + id: string, + overrides: Partial = {}, +): CanonicalSkillSearchCandidate { + return { + id, + source: "clawhub", + relevance: { tier: 2, lexicalScore: 0, semanticScore: 0 }, + official: false, + featured: false, + rolling60DayInstalls: 0, + bookmarks: 0, + updatedAt: 0, + ...overrides, + }; +} + +describe("canonical mixed skill search ranking", () => { + it("keeps an exact lexical match above an irrelevant popular result", () => { + const exact = candidate("clawhub:exact", { + relevance: { tier: 0, lexicalScore: 100, semanticScore: 0 }, + }); + const irrelevant = candidate("clawhub:popular", { + relevance: { tier: 5, lexicalScore: 0, semanticScore: 0.99 }, + rolling60DayInstalls: 1_000_000, + bookmarks: 1_000_000, + }); + + expect( + [irrelevant, exact].sort(compareCanonicalSkillSearchCandidates).map((row) => row.id), + ).toEqual(["clawhub:exact", "clawhub:popular"]); + }); + + it("treats owner-qualified native and external identities as exact matches", () => { + expect( + classifyCanonicalSkillSearchMatch("openclaw/calendar", { + identities: ["openclaw/calendar"], + name: "Calendar", + slug: "calendar", + taxonomy: [], + summary: null, + }), + ).toMatchObject({ tier: 0 }); + expect( + classifyCanonicalSkillSearchMatch("vercel-labs/skills/find-skills", { + identities: ["vercel-labs/skills/find-skills", "skills-sh/vercel-labs/skills/find-skills"], + name: "Find Skills", + slug: "find-skills", + taxonomy: [], + summary: null, + }), + ).toMatchObject({ tier: 0 }); + expect( + classifyCanonicalSkillSearchMatch("skills-sh/vercel-labs/skills/find-skills", { + identities: ["vercel-labs/skills/find-skills", "skills-sh/vercel-labs/skills/find-skills"], + name: "Find Skills", + slug: "find-skills", + taxonomy: [], + summary: null, + }), + ).toMatchObject({ tier: 0 }); + }); + + it("keeps taxonomy and summary intent below navigational lexical matches", () => { + const taxonomy = classifyCanonicalSkillSearchMatch("calendar automation", { + identities: ["acme/scheduler"], + name: "Scheduler", + slug: "scheduler", + taxonomy: ["calendar automation"], + summary: "Coordinate recurring meetings.", + }); + const summary = classifyCanonicalSkillSearchMatch("coordinate recurring meetings", { + identities: ["acme/meeting-helper"], + name: "Meeting Helper", + slug: "meeting-helper", + taxonomy: [], + summary: "Coordinate recurring meetings across teams.", + }); + + expect(taxonomy).toMatchObject({ tier: 3 }); + expect(summary).toMatchObject({ tier: 4 }); + }); + + it("uses official and featured only after lexical relevance is tied", () => { + const betterLexical = candidate("clawhub:better", { + relevance: { tier: 1, lexicalScore: 96, semanticScore: 0 }, + }); + const official = candidate("clawhub:official", { + relevance: { tier: 1, lexicalScore: 95, semanticScore: 0 }, + official: true, + featured: true, + }); + const tiedCommunity = candidate("clawhub:community", { + relevance: { tier: 1, lexicalScore: 95, semanticScore: 0 }, + }); + + expect( + [official, betterLexical, tiedCommunity] + .sort(compareCanonicalSkillSearchCandidates) + .map((row) => row.id), + ).toEqual(["clawhub:better", "clawhub:official", "clawhub:community"]); + }); + + it("uses rolling adoption, bookmarks, and freshness for comparable matches", () => { + const recentAdoption = candidate("clawhub:recent-adoption", { + rolling60DayInstalls: 12, + }); + const staleLifetimePopularity = candidate("skills-sh:stale-lifetime-popularity", { + source: "skills-sh", + bookmarks: 10_000, + updatedAt: 1, + }); + const fresh = candidate("clawhub:fresh", { updatedAt: 3 }); + + expect( + [staleLifetimePopularity, fresh, recentAdoption] + .sort(compareCanonicalSkillSearchCandidates) + .map((row) => row.id), + ).toEqual(["clawhub:recent-adoption", "skills-sh:stale-lifetime-popularity", "clawhub:fresh"]); + }); + + it("uses semantic recall only after all lexical evidence tiers", () => { + const semantic = classifyCanonicalSkillSearchMatch("help me plan a trip", { + identities: ["acme/travel-agent"], + name: "Travel Agent", + slug: "travel-agent", + taxonomy: [], + summary: null, + semanticScore: 0.92, + }); + expect(semantic).toEqual({ tier: 5, lexicalScore: 0, semanticScore: 0.92 }); + }); +}); diff --git a/convex/lib/canonicalSkillSearch.ts b/convex/lib/canonicalSkillSearch.ts new file mode 100644 index 00000000..f524b55c --- /dev/null +++ b/convex/lib/canonicalSkillSearch.ts @@ -0,0 +1,113 @@ +import { tokenize } from "./searchText"; + +export type CanonicalSkillSearchDocument = { + identities: string[]; + name: string; + slug: string; + taxonomy: string[]; + summary: string | null; + content?: string | null; + semanticScore?: number; +}; + +function normalizeIdentity(value: string) { + return value.trim().replace(/^@/, "").toLowerCase(); +} + +function allTokensMatch( + queryTokens: string[], + values: string[], + predicate: (candidate: string, query: string) => boolean, +) { + const candidateTokens = values.flatMap(tokenize); + return queryTokens.every((queryToken) => + candidateTokens.some((candidateToken) => predicate(candidateToken, queryToken)), + ); +} + +export function classifyCanonicalSkillSearchMatch( + query: string, + document: CanonicalSkillSearchDocument, +): CanonicalSkillSearchCandidate["relevance"] | null { + const identityQuery = normalizeIdentity(query); + if (document.identities.some((identity) => normalizeIdentity(identity) === identityQuery)) { + return { tier: 0, lexicalScore: 120, semanticScore: 0 }; + } + + const queryTokens = tokenize(query); + if (queryTokens.length === 0) return null; + const normalizedQuery = queryTokens.join(" "); + const normalizedSlug = tokenize(document.slug).join(" "); + const normalizedName = tokenize(document.name).join(" "); + if (normalizedSlug === normalizedQuery || normalizedName === normalizedQuery) { + return { tier: 0, lexicalScore: 110, semanticScore: 0 }; + } + + const navigationalFields = [document.slug, document.name, ...document.identities]; + if ( + allTokensMatch(queryTokens, navigationalFields, (candidate, needle) => candidate === needle) + ) { + return { tier: 1, lexicalScore: 95, semanticScore: 0 }; + } + if ( + allTokensMatch(queryTokens, navigationalFields, (candidate, needle) => + candidate.startsWith(needle), + ) + ) { + return { tier: 2, lexicalScore: 80, semanticScore: 0 }; + } + if ( + allTokensMatch(queryTokens, document.taxonomy, (candidate, needle) => + candidate.startsWith(needle), + ) + ) { + return { tier: 3, lexicalScore: 60, semanticScore: 0 }; + } + if ( + queryTokens.every((token) => token.length >= 3) && + allTokensMatch( + queryTokens, + [document.summary ?? "", document.content ?? ""], + (candidate, needle) => candidate.startsWith(needle), + ) + ) { + return { tier: 4, lexicalScore: 40, semanticScore: 0 }; + } + if ((document.semanticScore ?? 0) >= 0.55) { + return { tier: 5, lexicalScore: 0, semanticScore: document.semanticScore ?? 0 }; + } + + return null; +} + +export type CanonicalSkillSearchCandidate = { + id: string; + source: "clawhub" | "skills-sh"; + relevance: { + tier: number; + lexicalScore: number; + semanticScore: number; + }; + official: boolean; + featured: boolean; + rolling60DayInstalls: number; + bookmarks: number; + updatedAt: number; +}; + +export function compareCanonicalSkillSearchCandidates( + left: CanonicalSkillSearchCandidate, + right: CanonicalSkillSearchCandidate, +) { + return ( + left.relevance.tier - right.relevance.tier || + right.relevance.lexicalScore - left.relevance.lexicalScore || + right.relevance.semanticScore - left.relevance.semanticScore || + Number(right.official) - Number(left.official) || + Number(right.featured) - Number(left.featured) || + right.rolling60DayInstalls - left.rolling60DayInstalls || + right.bookmarks - left.bookmarks || + right.updatedAt - left.updatedAt || + left.id.localeCompare(right.id) + ); +} diff --git a/convex/lib/canonicalSkillSearchBounds.ts b/convex/lib/canonicalSkillSearchBounds.ts new file mode 100644 index 00000000..dea490c6 --- /dev/null +++ b/convex/lib/canonicalSkillSearchBounds.ts @@ -0,0 +1,9 @@ +export const CANONICAL_SKILL_SEARCH_BOUNDS = { + resultLimit: 100, + nativeCandidateLimit: 100, + vectorCandidateLimit: 128, + externalCandidateLimitPerIndex: 50, + externalIndexedReadCount: 6, + rollingAdoptionDays: 60, + rollingUsageBatchSize: 40, +} as const; diff --git a/convex/lib/canonicalSkillSearchResponse.ts b/convex/lib/canonicalSkillSearchResponse.ts new file mode 100644 index 00000000..41310639 --- /dev/null +++ b/convex/lib/canonicalSkillSearchResponse.ts @@ -0,0 +1,75 @@ +type LegacySearchResult = { + score?: unknown; + skill?: { + slug?: unknown; + displayName?: unknown; + summary?: unknown; + updatedAt?: unknown; + stats?: { downloads?: unknown }; + } | null; + version?: { version?: unknown } | null; + ownerHandle?: unknown; + owner?: { + handle?: unknown; + displayName?: unknown; + image?: unknown; + } | null; +}; + +function isCanonicalResult(value: unknown): value is Record { + if (!value || typeof value !== "object") return false; + const source = (value as { source?: unknown }).source; + return source === "clawhub" || source === "skills-sh"; +} + +function toLegacyOwner(value: unknown) { + if (!value || typeof value !== "object") return null; + const owner = value as { handle?: unknown; displayName?: unknown; image?: unknown }; + return { + handle: typeof owner.handle === "string" ? owner.handle : null, + displayName: typeof owner.displayName === "string" ? owner.displayName : null, + image: typeof owner.image === "string" ? owner.image : null, + }; +} + +/** Preserve canonical action order/shape while supporting older action rows. */ +export function serializeCanonicalSkillSearchResults(results: unknown[]) { + return results.map((result) => { + if (isCanonicalResult(result)) { + if (result.source !== "clawhub") return result; + const native = + result.native && typeof result.native === "object" + ? (result.native as { owner?: unknown }) + : null; + return { + ...result, + owner: toLegacyOwner(result.publisher ?? native?.owner), + }; + } + const legacy = (result ?? {}) as LegacySearchResult; + const owner = legacy.owner + ? { + handle: typeof legacy.owner.handle === "string" ? legacy.owner.handle : null, + displayName: + typeof legacy.owner.displayName === "string" ? legacy.owner.displayName : null, + image: typeof legacy.owner.image === "string" ? legacy.owner.image : null, + } + : null; + return { + score: typeof legacy.score === "number" ? legacy.score : 0, + slug: typeof legacy.skill?.slug === "string" ? legacy.skill.slug : undefined, + displayName: + typeof legacy.skill?.displayName === "string" ? legacy.skill.displayName : undefined, + summary: typeof legacy.skill?.summary === "string" ? legacy.skill.summary : null, + version: typeof legacy.version?.version === "string" ? legacy.version.version : null, + downloads: + typeof legacy.skill?.stats?.downloads === "number" + ? legacy.skill.stats.downloads + : undefined, + updatedAt: typeof legacy.skill?.updatedAt === "number" ? legacy.skill.updatedAt : undefined, + ownerHandle: + typeof legacy.ownerHandle === "string" ? legacy.ownerHandle : (owner?.handle ?? null), + owner, + }; + }); +} diff --git a/convex/schema.ts b/convex/schema.ts index 3e594163..475539d3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3248,6 +3248,7 @@ const skillsShMirrorDigests = defineTable({ displayName: v.string(), normalizedDisplayName: v.string(), normalizedDisplayNameFirstToken: v.string(), + searchSummary: v.optional(v.string()), searchText: v.string(), sourceUrl: v.string(), canonicalRepoUrl: v.optional(v.string()), @@ -3281,8 +3282,10 @@ const skillsShMirrorDigests = defineTable({ sourceSnapshotId: v.string(), lastObservedRunId: v.id("skillsShMirrorRuns"), active: v.boolean(), - publicVisible: v.literal(false), - installable: v.literal(false), + // Mirror ingestion always writes both flags false. Separately accepted + // activation work may opt an exact row into public search/install surfaces. + publicVisible: v.boolean(), + installable: v.boolean(), tombstonedAt: v.optional(v.number()), firstObservedAt: v.number(), lastObservedAt: v.number(), @@ -3290,6 +3293,36 @@ const skillsShMirrorDigests = defineTable({ updatedAt: v.number(), }) .index("by_external_id", ["externalId"]) + .index("by_active_visible_installable_fresh_slug", { + fields: ["active", "publicVisible", "installable", "sourceFreshnessStatus", "normalizedSlug"], + }) + .index("by_active_visible_installable_fresh_display", { + fields: [ + "active", + "publicVisible", + "installable", + "sourceFreshnessStatus", + "normalizedDisplayName", + ], + }) + .index("by_active_visible_installable_fresh_slug_token", { + fields: [ + "active", + "publicVisible", + "installable", + "sourceFreshnessStatus", + "normalizedSlugFirstToken", + ], + }) + .index("by_active_visible_installable_fresh_display_token", { + fields: [ + "active", + "publicVisible", + "installable", + "sourceFreshnessStatus", + "normalizedDisplayNameFirstToken", + ], + }) .index("by_active_and_normalized_slug", { fields: ["active", "normalizedSlug"], }) @@ -3316,7 +3349,7 @@ const skillsShMirrorDigests = defineTable({ }) .searchIndex("search_by_search_text", { searchField: "searchText", - filterFields: ["active"], + filterFields: ["active", "publicVisible", "installable", "sourceFreshnessStatus"], }); const skillsShMirrorDetails = defineTable({ diff --git a/convex/search.test.ts b/convex/search.test.ts index 9d87afe0..e2e4208b 100644 --- a/convex/search.test.ts +++ b/convex/search.test.ts @@ -1,14 +1,19 @@ /* @vitest-environment node */ +import { getFunctionName } from "convex/server"; import { describe, expect, it, vi } from "vitest"; import { tokenize } from "./lib/searchText"; import { __test, directPrefixSkillMatches, + getExternalSkillSearchCandidates, getExactSkillSlugMatch, + getOwnerQualifiedSkillMatch, + getRollingSkillSearchUsage, hydrateResults, lexicalFallbackSkills, - searchSkills, + searchSkills as canonicalSearchSkills, + searchNativeSkills, } from "./search"; const { generateEmbeddingMock } = vi.hoisted(() => ({ @@ -29,11 +34,17 @@ type WrappedHandler = { }; const searchSkillsHandler = ( - searchSkills as unknown as WrappedHandler<{ + searchNativeSkills as unknown as WrappedHandler<{ skill: { slug: string; _id: string }; score: number; + semanticScore: number; }> )._handler; +const canonicalSearchSkillsHandler = ( + canonicalSearchSkills as unknown as { + _handler: (ctx: unknown, args: unknown) => Promise>>; + } +)._handler; const lexicalFallbackSkillsHandler = (lexicalFallbackSkills as unknown as WrappedHandler)._handler; const directPrefixSkillMatchesHandler = (directPrefixSkillMatches as unknown as WrappedHandler) ._handler; @@ -51,6 +62,27 @@ const getExactSkillSlugMatchHandler = ( >; } )._handler; +const getOwnerQualifiedSkillMatchHandler = ( + getOwnerQualifiedSkillMatch as unknown as { + _handler: ( + ctx: unknown, + args: unknown, + ) => Promise>; + } +)._handler; +const getExternalSkillSearchCandidatesHandler = ( + getExternalSkillSearchCandidates as unknown as { + _handler: (ctx: unknown, args: unknown) => Promise>; + } +)._handler; +const getRollingSkillSearchUsageHandler = ( + getRollingSkillSearchUsage as unknown as { + _handler: ( + ctx: unknown, + args: unknown, + ) => Promise>; + } +)._handler; const hydrateResultsHandler = ( hydrateResults as unknown as { _handler: ( @@ -867,6 +899,25 @@ describe("search helpers", () => { expect(result).toEqual([]); }); + it("excludes unfeatured skills from featured-only exact slug search", async () => { + const exactSlugSkill = makeSkillDoc({ + id: "skills:unfeatured", + slug: "unfeatured", + displayName: "Unfeatured", + }); + const ctx = makeLexicalCtx({ + exactSlugSkill, + recentSkills: [], + }); + + const result = await getExactSkillSlugMatchHandler(ctx, { + slug: "unfeatured", + highlightedOnly: true, + }); + + expect(result).toEqual([]); + }); + it("returns duplicate exact slug matches without requiring global slug uniqueness", async () => { const ctx = makeLexicalCtx({ exactSlugSkills: [ @@ -915,6 +966,464 @@ describe("search helpers", () => { expect(result[0]?.owner?.official).toBe(true); }); + it("resolves an owner-qualified skill through indexed publisher and skill lookups", async () => { + const skill = makeSkillDoc({ + id: "skills:org-demo", + slug: "demo", + displayName: "Org Demo", + ownerPublisherId: "publishers:org", + }); + const usedIndexes: string[] = []; + const ctx = { + db: { + query: vi.fn((table: string) => ({ + withIndex: (index: string) => { + usedIndexes.push(`${table}.${index}`); + return { + unique: vi.fn(async () => { + if (table === "publishers") { + return { + _id: "publishers:org", + _creationTime: 1, + kind: "org", + handle: "org", + displayName: "Org", + createdAt: 1, + updatedAt: 1, + }; + } + if (table === "skills") return skill; + return null; + }), + }; + }, + })), + get: vi.fn(async (id: string) => + id === "skillVersions:1" + ? { _id: id, skillId: skill._id, softDeletedAt: undefined } + : null, + ), + }, + }; + + const result = await getOwnerQualifiedSkillMatchHandler(ctx, { + owner: "@ORG", + slug: "demo", + }); + + expect(result.map((entry) => entry.skill.slug)).toEqual(["demo"]); + expect(usedIndexes).toEqual([ + "publishers.by_handle", + "skills.by_owner_publisher_slug", + "officialPublishers.by_publisher", + ]); + await expect( + getOwnerQualifiedSkillMatchHandler(ctx, { + owner: "@ORG", + slug: "demo", + highlightedOnly: true, + }), + ).resolves.toEqual([]); + }); + + it("resolves an owner-qualified legacy user-owned skill", async () => { + const user = { + _id: "users:owner", + _creationTime: 1, + handle: "legacy", + displayName: "Legacy User", + createdAt: 1, + updatedAt: 1, + }; + const skill = makeSkillDoc({ + id: "skills:legacy-demo", + slug: "demo", + displayName: "Legacy Demo", + ownerPublisherId: undefined, + }); + const usedIndexes: string[] = []; + const ctx = { + db: { + query: vi.fn((table: string) => ({ + withIndex: (index: string) => { + usedIndexes.push(`${table}.${index}`); + return { + unique: vi.fn(async () => { + if (table === "publishers") return null; + if (table === "users" && index === "handle") return user; + if (table === "skills" && index === "by_owner_slug") return skill; + if (table === "officialPublishers") return null; + return null; + }), + }; + }, + })), + get: vi.fn(async (id: string) => { + if (id === user._id) return user; + if (id === "skillVersions:1") { + return { _id: id, skillId: skill._id, softDeletedAt: undefined }; + } + return null; + }), + }, + }; + + const result = await getOwnerQualifiedSkillMatchHandler(ctx, { + owner: "@LEGACY", + slug: "demo", + }); + + expect(result.map((entry) => entry.skill.slug)).toEqual(["demo"]); + expect(result[0]?.ownerHandle).toBe("legacy"); + expect(usedIndexes).toEqual([ + "publishers.by_handle", + "users.handle", + "skills.by_owner_slug", + "publishers.by_linked_user", + "officialPublishers.by_publisher", + ]); + }); + + it("fails owner-qualified lookup closed when no installable public version resolves", async () => { + const skill = makeSkillDoc({ + id: "skills:org-demo", + slug: "demo", + displayName: "Org Demo", + ownerPublisherId: "publishers:org", + moderationReason: "pending.scan", + statsVersions: 2, + }); + const ctx = { + db: { + query: vi.fn((table: string) => ({ + withIndex: () => + table === "skillVersions" + ? { order: () => ({ take: vi.fn().mockResolvedValue([]) }) } + : { + unique: vi.fn(async () => { + if (table === "publishers") { + return { + _id: "publishers:org", + _creationTime: 1, + kind: "org", + handle: "org", + displayName: "Org", + createdAt: 1, + updatedAt: 1, + }; + } + if (table === "skills") return skill; + return null; + }), + }, + })), + get: vi.fn().mockResolvedValue(null), + }, + }; + + await expect( + getOwnerQualifiedSkillMatchHandler(ctx, { owner: "org", slug: "demo" }), + ).resolves.toEqual([]); + }); + + it("filters external candidates by visibility and installability and bounds every recall index", async () => { + const visible = makeExternalSearchDigest({ externalId: "acme/skills/calendar" }); + const hidden = makeExternalSearchDigest({ + externalId: "acme/skills/hidden", + publicVisible: false, + }); + const blocked = makeExternalSearchDigest({ + externalId: "acme/skills/blocked", + installable: false, + }); + const takeLimits: number[] = []; + const usedIndexes: string[] = []; + const equalityFields: string[] = []; + const makeRange = (rows: unknown[]) => ({ + take: vi.fn(async (limit: number) => { + takeLimits.push(limit); + return rows; + }), + }); + const queryBuilder = { + eq: (field: string) => { + equalityFields.push(field); + return queryBuilder; + }, + gte: () => queryBuilder, + lt: () => queryBuilder, + search: () => queryBuilder, + }; + const ctx = { + db: { + query: vi.fn(() => ({ + withIndex: (index: string, build: (q: typeof queryBuilder) => unknown) => { + usedIndexes.push(index); + build(queryBuilder); + if (index === "by_external_id") { + return { unique: vi.fn(async () => visible) }; + } + return makeRange([visible, hidden, blocked]); + }, + withSearchIndex: (index: string, build: (q: typeof queryBuilder) => unknown) => { + usedIndexes.push(index); + build(queryBuilder); + return makeRange([visible, hidden, blocked]); + }, + })), + }, + }; + + const result = await getExternalSkillSearchCandidatesHandler(ctx, { + query: "acme/skills/calendar", + exactExternalId: "acme/skills/calendar", + }); + + expect(result.map((row) => row.externalId)).toEqual([visible.externalId]); + expect(takeLimits).toEqual([50, 50, 50, 50, 50]); + expect(usedIndexes).toEqual([ + "by_external_id", + "by_active_visible_installable_fresh_slug", + "by_active_visible_installable_fresh_display", + "by_active_visible_installable_fresh_slug_token", + "by_active_visible_installable_fresh_display_token", + "search_by_search_text", + ]); + expect(equalityFields).toEqual( + expect.arrayContaining(["active", "publicVisible", "installable", "sourceFreshnessStatus"]), + ); + }); + + it("aggregates only the bounded 60-day install and bookmark window", async () => { + const take = vi.fn().mockResolvedValue([ + { installs: 4, bookmarks: 2 }, + { installs: 5, bookmarks: 3 }, + ]); + const queryBuilder = { + eq: () => queryBuilder, + gte: () => queryBuilder, + lte: () => queryBuilder, + }; + const withIndex = vi.fn((_index: string, build: (q: typeof queryBuilder) => unknown) => { + build(queryBuilder); + return { take }; + }); + + const result = await getRollingSkillSearchUsageHandler( + { db: { query: vi.fn(() => ({ withIndex })) } }, + { skillIds: ["skills:demo"], startDay: 100, endDay: 159 }, + ); + + expect(result).toEqual([{ skillId: "skills:demo", installs: 9, bookmarks: 5 }]); + expect(withIndex).toHaveBeenCalledWith("by_skill_day", expect.any(Function)); + expect(take).toHaveBeenCalledWith(60); + }); + + it("rejects rolling usage batches that could exceed one query transaction budget", async () => { + await expect( + getRollingSkillSearchUsageHandler( + { db: { query: vi.fn() } }, + { + skillIds: Array.from({ length: 41 }, (_, index) => `skills:${index}`), + startDay: 100, + endDay: 159, + }, + ), + ).rejects.toThrow("skillIds exceeds 40"); + }); + + it("returns one ordered native and external contract with canonical routes and install refs", async () => { + generateEmbeddingMock.mockRejectedValueOnce(new Error("embedding unavailable")); + const native = { + skill: makePublicSkill({ + id: "skills:calendar", + slug: "calendar", + displayName: "Calendar", + downloads: 1_000_000, + }), + version: null, + ownerHandle: "openclaw", + owner: { + _id: "publishers:openclaw", + kind: "org", + handle: "openclaw", + displayName: "OpenClaw", + }, + }; + const external = { + ...makeExternalSearchDigest({ externalId: "acme/skills/calendar" }), + owner: "acme", + repo: "skills", + upstreamInstalls: 10_000_000, + }; + const runQuery = vi.fn(async (ref: Parameters[0]) => { + switch (getFunctionName(ref)) { + case "search:getExactSkillSlugMatch": + case "search:directPrefixSkillMatches": + return [native]; + case "search:lexicalFallbackSkills": + return []; + case "search:getExternalSkillSearchCandidates": + return [external]; + case "search:getRollingSkillSearchUsage": + return [{ skillId: native.skill._id, installs: 12, bookmarks: 3 }]; + default: + throw new Error(`Unexpected query ${getFunctionName(ref)}`); + } + }); + + const result = await canonicalSearchSkillsHandler( + { runQuery, vectorSearch: vi.fn() }, + { query: "calendar", limit: 10 }, + ); + + expect(result.map((row) => row.source)).toEqual(["clawhub", "skills-sh"]); + expect(result[0]).toMatchObject({ + canonicalUrl: "/openclaw/skills/calendar", + install: { reference: "openclaw/calendar" }, + metrics: { rolling60DayInstalls: 12, bookmarks: 3 }, + }); + expect((result[0]?.native as { owner?: unknown })?.owner).not.toHaveProperty("bio"); + expect(result[1]).toMatchObject({ + canonicalUrl: "/skills-sh/acme/skills/calendar", + links: { + canonical: "/skills-sh/acme/skills/calendar", + source: "https://skills.sh/acme/skills/calendar", + }, + install: { reference: "skills-sh/acme/skills/calendar" }, + sourceIdentity: { lifetimeInstalls: 10_000_000 }, + }); + }); + + it("excludes pending owner-qualified matches when pending scans are disabled", async () => { + generateEmbeddingMock.mockRejectedValueOnce(new Error("embedding unavailable")); + const pending = { + skill: makePublicSkill({ + id: "skills:pending-calendar", + slug: "calendar", + displayName: "Calendar", + ownerPublisherId: "publishers:openclaw", + githubScanStatus: "pending", + }), + version: null, + ownerHandle: "openclaw", + owner: { + _id: "publishers:openclaw", + kind: "org", + handle: "openclaw", + displayName: "OpenClaw", + }, + }; + const runQuery = vi.fn(async (ref: Parameters[0]) => { + switch (getFunctionName(ref)) { + case "search:directPrefixSkillMatches": + case "search:lexicalFallbackSkills": + case "search:getExternalSkillSearchCandidates": + return []; + case "search:getOwnerQualifiedSkillMatch": + return [pending]; + case "search:getRollingSkillSearchUsage": + return [{ skillId: pending.skill._id, installs: 0, bookmarks: 0 }]; + default: + throw new Error(`Unexpected query ${getFunctionName(ref)}`); + } + }); + + const result = await canonicalSearchSkillsHandler( + { runQuery, vectorSearch: vi.fn() }, + { query: "openclaw/calendar", limit: 10, excludePendingScan: true }, + ); + + expect(result).toEqual([]); + }); + + it("excludes unfeatured owner-qualified matches from featured-only search", async () => { + generateEmbeddingMock.mockRejectedValueOnce(new Error("embedding unavailable")); + const runQuery = vi.fn(async (ref: Parameters[0]) => { + switch (getFunctionName(ref)) { + case "search:directPrefixSkillMatches": + case "search:lexicalFallbackSkills": + case "search:getExternalSkillSearchCandidates": + return []; + case "search:getOwnerQualifiedSkillMatch": + return []; + case "search:getRollingSkillSearchUsage": + return []; + default: + throw new Error(`Unexpected query ${getFunctionName(ref)}`); + } + }); + + const result = await canonicalSearchSkillsHandler( + { runQuery, vectorSearch: vi.fn() }, + { query: "openclaw/calendar", limit: 10, highlightedOnly: true }, + ); + + expect(result).toEqual([]); + expect(runQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + owner: "openclaw", + slug: "calendar", + highlightedOnly: true, + }), + ); + }); + + it("keeps exact-token native recall ahead of semantic prefix hits at the candidate bound", async () => { + generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]); + const exact = { + skill: makePublicSkill({ + id: "skills:exact-calendar-sync", + slug: "sync-tool-exact", + displayName: "Calendar", + }), + version: null, + ownerHandle: "openclaw", + owner: null, + }; + const prefixes = Array.from({ length: 100 }, (_, index) => ({ + embeddingId: `skillEmbeddings:prefix-${index}`, + skill: makePublicSkill({ + id: `skills:prefix-${index}`, + slug: `calendars-synchronizer-${index}`, + displayName: `Calendars Synchronizer ${index}`, + }), + version: null, + ownerHandle: "community", + owner: null, + })); + const runQuery = vi.fn(async (ref: Parameters[0]) => { + switch (getFunctionName(ref)) { + case "search:directPrefixSkillMatches": + return [exact, ...prefixes.map(({ embeddingId: _embeddingId, ...entry }) => entry)]; + case "search:hydrateResults": + return prefixes; + case "search:getExternalSkillSearchCandidates": + return []; + case "search:getRollingSkillSearchUsage": + return []; + default: + throw new Error(`Unexpected query ${getFunctionName(ref)}`); + } + }); + + const result = await canonicalSearchSkillsHandler( + { + runQuery, + vectorSearch: vi.fn().mockResolvedValue( + prefixes.map((entry) => ({ + _id: entry.embeddingId, + _score: 0.99, + })), + ), + }, + { query: "calendar sync", limit: 10 }, + ); + + expect(result[0]).toMatchObject({ id: "clawhub:skills:exact-calendar-sync" }); + }); + it("filters duplicate exact slug matches by topic", async () => { const ctx = makeLexicalCtx({ exactSlugSkills: [ @@ -1225,13 +1734,13 @@ describe("search helpers", () => { ); expect(result).toHaveLength(2); - expect(result[0].skill.slug).toBe("foo-b"); + expect(result[0].skill.slug).toBe("foo-a"); expect(new Set(result.map((entry: { skill: { _id: string } }) => entry.skill._id)).size).toBe( 2, ); }); - it("uses a stable recall pool before slicing first-page search results (#1756)", async () => { + it("uses a stable recall pool without lifetime popularity changing the first page", async () => { generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]); const vectorEntries = Array.from({ length: 25 }, (_, index) => ({ @@ -1287,7 +1796,7 @@ describe("search helpers", () => { expect.objectContaining({ query: "image", limit: 200 }), ); expect(result).toHaveLength(25); - expect(result.some((entry) => entry.skill.slug === "antigravity-image-generator")).toBe(true); + expect(result.some((entry) => entry.skill.slug === "antigravity-image-generator")).toBe(false); }); it("orders lexical name matches above summary-only matches before popularity", async () => { @@ -1336,7 +1845,7 @@ describe("search helpers", () => { expect(result[0]).not.toHaveProperty("matchReason"); }); - it("does not let vector recall make short summary-only skills eligible", async () => { + it("admits strong semantic recall without promoting it above lexical tiers", async () => { generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]); const summaryOnly = { embeddingId: "skillEmbeddings:ai", @@ -1368,7 +1877,8 @@ describe("search helpers", () => { { query: "ai", limit: 10 }, ); - expect(result).toEqual([]); + expect(result.map((entry) => entry.skill.slug)).toEqual(["general-helper"]); + expect(result[0]?.semanticScore).toBe(0.99); }); it("always includes an exact slug match even when vector exact matches already fill the limit", async () => { @@ -1835,16 +2345,23 @@ describe("search helpers", () => { expect(__test.getNextCandidateLimit(1000, 1000)).toBeNull(); }); + it("normalizes native owner-qualified and skills.sh install identities", () => { + expect(__test.parseQualifiedSearchIdentity("@OpenClaw/Calendar")).toEqual({ + native: { owner: "openclaw", slug: "calendar" }, + external: "openclaw/calendar", + }); + expect(__test.parseQualifiedSearchIdentity("skills-sh/Vercel-Labs/Skills/Find-Skills")).toEqual( + { + native: null, + external: "vercel-labs/skills/find-skills", + }, + ); + }); + it("boosts exact slug/name matches over loose matches", () => { const queryTokens = tokenize("notion"); - const exactScore = __test.scoreSkillResult(queryTokens, 0.4, "Notion Sync", "notion-sync", { - installsAllTime: 0, - stars: 0, - }); - const looseScore = __test.scoreSkillResult(queryTokens, 0.6, "Notes Sync", "notes-sync", { - installsAllTime: 100, - stars: 20, - }); + const exactScore = __test.scoreSkillResult(queryTokens, 0.4, "Notion Sync", "notion-sync"); + const looseScore = __test.scoreSkillResult(queryTokens, 0.6, "Notes Sync", "notes-sync"); expect(exactScore).toBeGreaterThan(looseScore); }); @@ -1855,70 +2372,47 @@ describe("search helpers", () => { 0.5, "Self Improving Agent", "self-improving-agent", - { installsAllTime: 0, stars: 0 }, ); const containingScore = __test.scoreSkillResult( queryTokens, 0.6, "Self Improving Agent", "xiucheng-self-improving-agent", - { installsAllTime: 50, stars: 10 }, ); expect(exactScore).toBeGreaterThan(containingScore); }); it("keeps extreme popularity below direct lexical relevance", () => { const queryTokens = tokenize("needle"); - const exactScore = __test.scoreSkillResult(queryTokens, 0, "Unrelated Name", "needle", { - installsAllTime: 0, - stars: 0, - }); + const exactScore = __test.scoreSkillResult(queryTokens, 0, "Unrelated Name", "needle"); const popularLooseScore = __test.scoreSkillResult( queryTokens, 0.9, "Different Tool", "different-tool", - { installsAllTime: 25_000, stars: 25_000 }, ); expect(exactScore).toBeGreaterThan(popularLooseScore); }); it("keeps popularity from flipping a strong name match", () => { const queryTokens = tokenize("notion"); - const nameMatchScore = __test.scoreSkillResult(queryTokens, 0, "Notion Helper", "helper", { - installsAllTime: 0, - stars: 0, - }); + const nameMatchScore = __test.scoreSkillResult(queryTokens, 0, "Notion Helper", "helper"); const popularVectorScore = __test.scoreSkillResult( queryTokens, 1, "Different Tool", "different-tool", - { installsAllTime: 25_000, stars: 25_000 }, ); expect(nameMatchScore).toBeGreaterThan(popularVectorScore); }); - it("adds stars and installs popularity for equally relevant matches", () => { + it("keeps lifetime stars and installs out of native candidate scoring", () => { const queryTokens = tokenize("notion"); - const noPopularity = __test.scoreSkillResult( - queryTokens, - 0.5, - "Notion Helper", - "notion-helper", - { installsAllTime: 0, stars: 0 }, - ); - const highInstallsOnly = __test.scoreSkillResult( - queryTokens, - 0.5, - "Notion Helper", - "notion-helper", - { installsAllTime: 1000, stars: 0 }, - ); - expect(highInstallsOnly).toBeGreaterThan(noPopularity); + const score = __test.scoreSkillResult(queryTokens, 0.5, "Notion Helper", "notion-helper"); + expect(score).toBe(__test.getLexicalBoost(queryTokens, "Notion Helper", "notion-helper") + 0.5); }); - it("uses installs popularity in live skill search scoring", async () => { + it("does not use lifetime installs or downloads in native candidate scoring", async () => { generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]); const installed = { embeddingId: "skillEmbeddings:installed", @@ -1969,7 +2463,7 @@ describe("search helpers", () => { expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-installed", "tool-downloaded"]); }); - it("breaks capped popularity ties by stars and installs before downloads", async () => { + it("does not use lifetime stars, installs, or downloads as a native tie-breaker", async () => { generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]); const installedOnly = { skill: makePublicSkill({ @@ -2014,10 +2508,9 @@ describe("search helpers", () => { expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-installed", "tool-downloaded"]); }); - it("keeps skills.sh installs out of the native download ranking tie-breaker", async () => { + it("keeps native and combined lifetime downloads out of native tie-breakers", async () => { generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]); const indexed = { - nativeDownloads: 10, skill: makePublicSkill({ id: "skills:indexed", slug: "tool-indexed", @@ -2031,7 +2524,6 @@ describe("search helpers", () => { owner: null, }; const native = { - nativeDownloads: 20, skill: makePublicSkill({ id: "skills:native", slug: "tool-native", @@ -2058,8 +2550,7 @@ describe("search helpers", () => { { query: "tool", limit: 2 }, ); - expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-native", "tool-indexed"]); - expect(result[0]).not.toHaveProperty("nativeDownloads"); + expect(result.map((entry) => entry.skill.slug)).toEqual(["tool-indexed", "tool-native"]); }); it("uses digest doc instead of full skill doc in hydrateResults but revalidates the owner", async () => { @@ -2470,6 +2961,36 @@ function makeSkillDoc(params: { }; } +function makeExternalSearchDigest(params: { + externalId: string; + publicVisible?: boolean; + installable?: boolean; +}) { + const [, , slug = "skill"] = params.externalId.split("/"); + return { + _id: `skillsShMirrorDigests:${params.externalId}`, + _creationTime: 1, + externalId: params.externalId, + slug, + displayName: slug, + normalizedSlug: slug, + normalizedSlugFirstToken: slug, + normalizedDisplayName: slug, + normalizedDisplayNameFirstToken: slug, + searchText: slug, + sourceUrl: `https://skills.sh/${params.externalId}`, + upstreamInstalls: 10, + upstreamScanners: {}, + inferredCategories: [], + inferredTopics: [], + sourceFreshnessStatus: "observed-only", + active: true, + publicVisible: params.publicVisible ?? true, + installable: params.installable ?? true, + lastObservedAt: 1, + }; +} + function makePaginatedRows(rows: T[], onPaginate?: () => void) { return vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => { onPaginate?.(); diff --git a/convex/search.ts b/convex/search.ts index 6d6a6de1..a427c102 100644 --- a/convex/search.ts +++ b/convex/search.ts @@ -9,10 +9,17 @@ import { import { v } from "convex/values"; import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; -import type { QueryCtx } from "./_generated/server"; +import type { ActionCtx, QueryCtx } from "./_generated/server"; import { action, internalQuery } from "./functions"; import { isSkillHighlighted } 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"; @@ -20,7 +27,11 @@ import { hasResolvablePublicBrowseVersionFromState, shouldExcludeSkillFromPublicBrowse, } from "./lib/publicBrowse"; -import { getOwnerPublisher } from "./lib/publishers"; +import { + getActiveUserByHandleOrPersonalPublisher, + getOwnerPublisher, + getPublisherByHandle, +} from "./lib/publishers"; import { matchesAllTokens, matchesExactTokens, @@ -35,7 +46,6 @@ import { normalizeSkillSearchText, } from "./lib/skillSearchDigest"; import { isSearchableSkillSlugShape, normalizeSkillSlug } from "./lib/skillSlugValidator"; -import { readCanonicalStat } from "./lib/skillStats"; type OwnerInfo = { ownerHandle: string | null; owner: PublicPublisher | null }; @@ -76,7 +86,6 @@ async function withOfficialOwnerInfo(ctx: Pick, ownerInfo: Owner type SkillSearchEntry = { embeddingId?: Id<"skillEmbeddings">; - nativeDownloads: number; skill: NonNullable>; version: Doc<"skillVersions"> | null; ownerHandle: string | null; @@ -90,9 +99,12 @@ type SearchMatch = { type SearchResult = SkillSearchEntry & SearchMatch & { score: number; + semanticScore: number; + candidateRelevance: CanonicalSkillSearchCandidate["relevance"]; }; -type PublicSearchResult = Omit & { +type PublicSearchResult = SkillSearchEntry & { score: number; + semanticScore: number; }; const EXACT_SLUG_BOOST = 2.5; @@ -100,9 +112,6 @@ 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 STAR_POPULARITY_WEIGHT = 0.12; -const INSTALL_POPULARITY_WEIGHT = 0.005; -const MAX_POPULARITY_BOOST = 0.09; const FALLBACK_SCAN_LIMIT = 2000; const MIN_FALLBACK_SCAN_LIMIT = 100; const FALLBACK_RECALL_MULTIPLIER = 2; @@ -114,7 +123,7 @@ const MAX_DIRECT_SKILL_TOPIC_CANDIDATES = 100; // 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 = 128; +const MAX_VECTOR_SEARCH_CANDIDATES = CANONICAL_SKILL_SEARCH_BOUNDS.vectorCandidateLimit; const MAX_EXACT_SLUG_MATCHES = 25; const EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH = 3; @@ -150,34 +159,21 @@ function getLexicalBoost(queryTokens: string[], displayName: string, slug: strin return boost; } -type PopularityStats = { - installsAllTime?: number; - stars: number; -}; - -function getPopularityBoost(stats: PopularityStats) { - const rawBoost = - Math.log1p(Math.max(stats.stars, 0)) * STAR_POPULARITY_WEIGHT + - Math.log1p(Math.max(stats.installsAllTime ?? 0, 0)) * INSTALL_POPULARITY_WEIGHT; - return Math.min(rawBoost, MAX_POPULARITY_BOOST); -} - function scoreSkillResult( queryTokens: string[], vectorScore: number, displayName: string, slug: string, - stats: PopularityStats, ) { const lexicalBoost = getLexicalBoost(queryTokens, displayName, slug); - const popularityBoost = getPopularityBoost(stats); - return vectorScore + lexicalBoost + popularityBoost; + return vectorScore + lexicalBoost; } function classifySkillMatch( query: string, queryTokens: string[], skill: Pick, + semanticScore = 0, ): SearchMatch | null { const needle = query.toLowerCase(); const normalizedSlugQuery = queryTokens.join("-"); @@ -227,22 +223,14 @@ function classifySkillMatch( ) { return { rankTier: 3 }; } + if (semanticScore >= 0.55) { + return { rankTier: 4 }; + } return null; } -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( - { stars: a.skill.stats.stars, installsAllTime: a.skill.stats.installs }, - { stars: b.skill.stats.stars, installsAllTime: b.skill.stats.installs }, - ) || - b.nativeDownloads - a.nativeDownloads - ); +function compareSkillTrust(a: SkillSearchEntry, b: SkillSearchEntry) { + return Number(Boolean(b.owner?.official)) - Number(Boolean(a.owner?.official)); } function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) { @@ -317,17 +305,28 @@ function prefixUpperBound(value: string) { return `${value}\uffff`; } -export const searchSkills: ReturnType = action({ - args: { - query: v.string(), - limit: v.optional(v.number()), - 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()), - }, - handler: async (ctx, args): Promise => { +const skillSearchArgs = { + query: v.string(), + limit: v.optional(v.number()), + 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()), +}; + +type SkillSearchArgs = { + query: string; + limit?: number; + highlightedOnly?: boolean; + nonSuspiciousOnly?: boolean; + excludePendingScan?: boolean; + categorySlug?: string; + topic?: string; +}; + +const nativeSkillSearch = { + async handler(ctx: ActionCtx, args: SkillSearchArgs): Promise { const query = args.query.trim(); if (!query) return []; const categorySlug = normalizeSkillCategoryFilter(args.categorySlug); @@ -484,34 +483,379 @@ export const searchSkills: ReturnType = action({ 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); + 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, - { - installsAllTime: entry.skill.stats.installs, - stars: entry.skill.stats.stars, - }, ), }; }) .filter((entry): entry is SearchResult => Boolean(entry?.skill)) .sort( (a, b) => - a.rankTier - b.rankTier || - b.score - a.score || - compareSkillTrustAndUsage(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( - ({ nativeDownloads: _nativeDownloads, rankTier: _rankTier, ...entry }) => entry, + ({ rankTier: _rankTier, candidateRelevance: _candidateRelevance, ...entry }) => entry, + ); + }, +}; + +export const searchNativeSkills: ReturnType = action({ + args: skillSearchArgs, + 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; + 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 | null; + 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; +// Forty candidates can read at most 2,400 daily rows, leaving headroom below +// Convex's per-transaction document/byte limits for imported production-shaped data. +const ROLLING_USAGE_QUERY_BATCH_SIZE = CANONICAL_SKILL_SEARCH_BOUNDS.rollingUsageBatchSize; + +function chunkValues(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.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, + 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}`], + 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, + 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: null, + }; +} + +export const searchSkills: ReturnType = action({ + args: skillSearchArgs, + handler: async (ctx, args): Promise => { + 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) + : Promise.resolve([]), + ctx.runQuery(internal.search.getExternalSkillSearchCandidates, { + query, + highlightedOnly: args.highlightedOnly, + categorySlug: args.categorySlug, + topic: args.topic, + ...(qualified.external ? { exactExternalId: qualified.external } : {}), + }) as Promise[]>, + ]); + + const nativeById = new Map(); + 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, + ), + ) + ).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, + ) + .sort(compareCanonicalSkillSearchCandidates) + .slice(0, limit); + + return ranked.map( + ({ + relevance: _relevance, + rolling60DayInstalls: _installs, + bookmarks: _bookmarks, + ...result + }) => result, ); }, }); @@ -520,6 +864,7 @@ 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()), }, @@ -538,14 +883,15 @@ export const getExactSkillSlugMatch = internalQuery({ 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 (!(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 = { - nativeDownloads: readCanonicalStat(skill, "downloads"), skill: publicSkill, version: null as Doc<"skillVersions"> | null, ownerHandle: resolved.ownerHandle, @@ -559,6 +905,221 @@ export const getExactSkillSlugMatch = internalQuery({ }, }); +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 => { + 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[]> => { + 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(); + 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 => { + 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(), @@ -849,6 +1410,9 @@ export const directPrefixSkillMatches = internalQuery({ if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null; if (args.highlightedOnly && !isSkillHighlighted(skill)) 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) @@ -856,7 +1420,6 @@ export const directPrefixSkillMatches = internalQuery({ const publicSkill = toPublicSearchSkill(skill); if (!publicSkill || !resolved.owner) return null; return { - nativeDownloads: readCanonicalStat(skill, "downloads"), skill: publicSkill, version: null as Doc<"skillVersions"> | null, ownerHandle: resolved.ownerHandle, @@ -926,7 +1489,6 @@ export const hydrateResults = internalQuery({ if (!publicSkill) return null; return { embeddingId, - nativeDownloads: readCanonicalStat(skill, "downloads"), skill: publicSkill, version: null as Doc<"skillVersions"> | null, ownerHandle: resolved.ownerHandle, @@ -1100,7 +1662,6 @@ export const lexicalFallbackSkills = internalQuery({ const publicSkill = toPublicSearchSkill(skill); if (!publicSkill) return null; return { - nativeDownloads: readCanonicalStat(skill, "downloads"), skill: publicSkill, version: null as Doc<"skillVersions"> | null, ownerHandle: resolved.ownerHandle, @@ -1125,4 +1686,5 @@ export const __test = { scoreSkillResult, classifySkillMatch, mergeUniqueBySkillId, + parseQualifiedSearchIdentity, }; diff --git a/convex/searchTestFixtures.test.ts b/convex/searchTestFixtures.test.ts new file mode 100644 index 00000000..9072a60e --- /dev/null +++ b/convex/searchTestFixtures.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + cleanupCanonicalSearchTestFixture, + readCanonicalSearchTestFixture, + seedCanonicalSearchTestFixture, +} from "./searchTestFixtures"; + +type WrappedHandler = { + _handler: (ctx: unknown, args: TArgs) => Promise; +}; + +const seedFixture = ( + seedCanonicalSearchTestFixture as unknown as WrappedHandler<{ + confirm: "manage-claw-577-canonical-search-test-fixture"; + }> +)._handler; +const readFixture = ( + readCanonicalSearchTestFixture as unknown as WrappedHandler<{ + confirm: "manage-claw-577-canonical-search-test-fixture"; + }> +)._handler; +const cleanupFixture = ( + cleanupCanonicalSearchTestFixture as unknown as WrappedHandler<{ + confirm: "manage-claw-577-canonical-search-test-fixture"; + digestId: string; + runId: string; + }> +)._handler; + +function chainEq(constraints: Record) { + return { + eq(field: string, value: unknown) { + constraints[field] = value; + return chainEq(constraints); + }, + }; +} + +function createDb() { + const tables: Record & { _id: string }>> = {}; + const counters: Record = {}; + const list = (table: string) => (tables[table] ??= []); + const db = { + normalizeId: (_table: string, id: string) => id, + get: async (arg0: string, arg1?: string) => { + const id = arg1 ?? arg0; + const table = id.split(":")[0] ?? ""; + return list(table).find((row) => row._id === id) ?? null; + }, + insert: async (table: string, value: Record) => { + const count = (counters[table] ?? 0) + 1; + counters[table] = count; + const row = { _id: `${table}:${count}`, _creationTime: count, ...value }; + list(table).push(row); + return row._id; + }, + patch: async () => { + throw new Error("fixture must not patch existing rows"); + }, + delete: async (arg0: string, arg1?: string) => { + const id = arg1 ?? arg0; + const table = id.split(":")[0] ?? ""; + const rows = list(table); + const index = rows.findIndex((row) => row._id === id); + if (index !== -1) rows.splice(index, 1); + }, + query: (table: string) => ({ + withIndex: (_name: string, build: (q: ReturnType) => unknown) => { + const constraints: Record = {}; + build(chainEq(constraints)); + const matched = () => + list(table).filter((row) => + Object.entries(constraints).every(([field, value]) => row[field] === value), + ); + return { + unique: async () => matched()[0] ?? null, + take: async (limit: number) => matched().slice(0, limit), + }; + }, + }), + }; + return { db, tables }; +} + +describe("canonical search permanent-Test fixture", () => { + beforeEach(() => { + vi.stubEnv("CLAWHUB_ENV", "test"); + vi.stubEnv("CLAWHUB_DISABLE_CRONS", "1"); + vi.stubEnv("CLAWHUB_DEPLOYMENT_NAME", "academic-chihuahua-392"); + }); + + afterEach(() => vi.unstubAllEnvs()); + + it("idempotently seeds one owned visible external popularity decoy", async () => { + const { db, tables } = createDb(); + const ctx = { db, scheduler: { runAfter: async () => null } }; + const args = { confirm: "manage-claw-577-canonical-search-test-fixture" as const }; + + const first = await seedFixture(ctx, args); + const second = await seedFixture(ctx, args); + + expect(first).toEqual(expect.objectContaining({ created: true })); + expect(second).toEqual( + expect.objectContaining({ + created: false, + digestId: (first as { digestId: string }).digestId, + runId: (first as { runId: string }).runId, + }), + ); + expect(tables.skillsShMirrorRuns).toHaveLength(1); + expect(tables.skillsShMirrorRuns?.[0]).toEqual( + expect.objectContaining({ + snapshotId: "claw-577-canonical-search-proof-v1", + actor: "CLAW-577 Test workflow", + counts: expect.objectContaining({ scansPlanned: 0, scansAdmitted: 0 }), + }), + ); + expect(tables.skillsShMirrorDigests).toHaveLength(1); + expect(tables.skillsShMirrorDigests?.[0]).toEqual( + expect.objectContaining({ + externalId: "clawhub-test/claw-577/search-popularity-decoy", + upstreamInstalls: 9_000_000, + active: true, + publicVisible: true, + installable: true, + sourceFreshnessStatus: "observed-only", + }), + ); + }); + + it("reads back and exactly cleans up only the owned IDs", async () => { + const { db, tables } = createDb(); + const ctx = { db, scheduler: { runAfter: async () => null } }; + const confirm = "manage-claw-577-canonical-search-test-fixture" as const; + const seeded = (await seedFixture(ctx, { confirm })) as { + digestId: string; + runId: string; + }; + const unrelatedRunId = await db.insert("skillsShMirrorRuns", { snapshotId: "unrelated" }); + + await expect(readFixture(ctx, { confirm })).resolves.toEqual( + expect.objectContaining({ + present: true, + digestId: seeded.digestId, + runId: seeded.runId, + upstreamInstalls: 9_000_000, + }), + ); + await expect(cleanupFixture(ctx, { confirm, ...seeded })).resolves.toEqual({ + ok: true, + removed: true, + }); + await expect(readFixture(ctx, { confirm })).resolves.toEqual({ present: false }); + await expect(cleanupFixture(ctx, { confirm, ...seeded })).resolves.toEqual({ + ok: true, + removed: false, + }); + expect(tables.skillsShMirrorRuns).toEqual([ + expect.objectContaining({ _id: unrelatedRunId, snapshotId: "unrelated" }), + ]); + expect(tables.skillsShMirrorDigests).toEqual([]); + }); + + it("rejects stale cleanup when a newer owned fixture occupies the exact identity", async () => { + const { db } = createDb(); + const ctx = { db, scheduler: { runAfter: async () => null } }; + const confirm = "manage-claw-577-canonical-search-test-fixture" as const; + const stale = (await seedFixture(ctx, { confirm })) as { digestId: string; runId: string }; + await cleanupFixture(ctx, { confirm, ...stale }); + await seedFixture(ctx, { confirm }); + + await expect(cleanupFixture(ctx, { confirm, ...stale })).rejects.toThrow( + "newer fixture occupies the owned identity", + ); + }); + + it("rejects an unrelated exact-ID collision without writing a run", async () => { + const { db, tables } = createDb(); + const ctx = { db, scheduler: { runAfter: async () => null } }; + await db.insert("skillsShMirrorDigests", { + externalId: "clawhub-test/claw-577/search-popularity-decoy", + sourceSnapshotId: "unrelated", + observationFingerprint: "unrelated", + sourceUrl: "https://example.invalid/unrelated", + lastObservedRunId: "skillsShMirrorRuns:404", + }); + + await expect( + seedFixture(ctx, { confirm: "manage-claw-577-canonical-search-test-fixture" }), + ).rejects.toThrow("digest ownership mismatch"); + expect(tables.skillsShMirrorRuns ?? []).toEqual([]); + }); + + it("fails closed on an orphaned owned run", async () => { + const { db } = createDb(); + const ctx = { db, scheduler: { runAfter: async () => null } }; + await db.insert("skillsShMirrorRuns", { + snapshotId: "claw-577-canonical-search-proof-v1", + sourceView: "leaderboard", + sourceSnapshotHash: "claw-577-canonical-search-proof-v1-owned", + status: "completed", + actor: "CLAW-577 Test workflow", + }); + + await expect( + readFixture(ctx, { confirm: "manage-claw-577-canonical-search-test-fixture" }), + ).rejects.toThrow("partial state"); + await expect( + seedFixture(ctx, { confirm: "manage-claw-577-canonical-search-test-fixture" }), + ).rejects.toThrow("partial state"); + }); + + it("refuses cleanup when the owned fixture gained dependent rows", async () => { + const { db, tables } = createDb(); + const ctx = { db, scheduler: { runAfter: async () => null } }; + const confirm = "manage-claw-577-canonical-search-test-fixture" as const; + const seeded = (await seedFixture(ctx, { confirm })) as { digestId: string; runId: string }; + await db.insert("skillsShMirrorDetails", { digestId: seeded.digestId }); + + await expect(cleanupFixture(ctx, { confirm, ...seeded })).rejects.toThrow("dependent rows"); + expect(tables.skillsShMirrorDigests).toHaveLength(1); + expect(tables.skillsShMirrorRuns).toHaveLength(1); + }); + + it("rechecks the permanent-Test environment at the mutation boundary", async () => { + vi.stubEnv("CLAWHUB_ENV", "production"); + const { db } = createDb(); + + await expect( + seedFixture( + { db, scheduler: { runAfter: async () => null } }, + { confirm: "manage-claw-577-canonical-search-test-fixture" }, + ), + ).rejects.toThrow("CLAWHUB_ENV=test"); + }); +}); diff --git a/convex/searchTestFixtures.ts b/convex/searchTestFixtures.ts new file mode 100644 index 00000000..5591ad49 --- /dev/null +++ b/convex/searchTestFixtures.ts @@ -0,0 +1,261 @@ +import { v } from "convex/values"; +import type { Doc } from "./_generated/dataModel"; +import { internalQuery, type QueryCtx } from "./_generated/server"; +import { internalMutation } from "./functions"; +import { assertTestSeedAllowed } from "./lib/testSeed"; + +const CONFIRM = "manage-claw-577-canonical-search-test-fixture"; +const EXTERNAL_ID = "clawhub-test/claw-577/search-popularity-decoy"; +const SNAPSHOT_ID = "claw-577-canonical-search-proof-v1"; +const SNAPSHOT_HASH = "claw-577-canonical-search-proof-v1-owned"; +const ACTOR = "CLAW-577 Test workflow"; + +const confirmArgs = { confirm: v.literal(CONFIRM) }; + +function assertOwnedRun(run: Doc<"skillsShMirrorRuns">) { + if ( + run.snapshotId !== SNAPSHOT_ID || + run.sourceView !== "leaderboard" || + run.sourceSnapshotHash !== SNAPSHOT_HASH || + run.actor !== ACTOR || + run.status !== "completed" || + run.counts.scansPlanned !== 0 || + run.counts.scansAdmitted !== 0 + ) { + throw new Error("CLAW-577 Test fixture run ownership mismatch"); + } +} + +function assertOwnedDigest(digest: Doc<"skillsShMirrorDigests">) { + if ( + digest.externalId !== EXTERNAL_ID || + digest.sourceSnapshotId !== SNAPSHOT_ID || + digest.observationFingerprint !== SNAPSHOT_ID || + digest.sourceUrl !== `https://skills.sh/${EXTERNAL_ID}` || + digest.sourceType !== "github" || + digest.owner !== "clawhub-test" || + digest.repo !== "claw-577" || + digest.slug !== "search-popularity-decoy" || + digest.upstreamInstalls !== 9_000_000 || + !digest.active || + !digest.publicVisible || + !digest.installable || + digest.sourceFreshnessStatus !== "observed-only" || + digest.detailStatus !== "missing" + ) { + throw new Error("CLAW-577 Test fixture digest ownership mismatch"); + } +} + +async function findOwnedRun(ctx: Pick) { + return await ctx.db + .query("skillsShMirrorRuns") + .withIndex("by_source_view_and_status_and_source_snapshot_hash", (q) => + q + .eq("sourceView", "leaderboard") + .eq("status", "completed") + .eq("sourceSnapshotHash", SNAPSHOT_HASH), + ) + .unique(); +} + +async function readOwnedFixture(ctx: Pick) { + const digest = await ctx.db + .query("skillsShMirrorDigests") + .withIndex("by_external_id", (q) => q.eq("externalId", EXTERNAL_ID)) + .unique(); + if (!digest) { + if (await findOwnedRun(ctx)) throw new Error("CLAW-577 Test fixture has partial state"); + return null; + } + assertOwnedDigest(digest); + const run: Doc<"skillsShMirrorRuns"> | null = await ctx.db.get( + "skillsShMirrorRuns", + digest.lastObservedRunId, + ); + if (!run) throw new Error("CLAW-577 Test fixture is missing its owned run"); + assertOwnedRun(run); + return { digest, run }; +} + +export const seedCanonicalSearchTestFixture = internalMutation({ + args: confirmArgs, + handler: async (ctx) => { + assertTestSeedAllowed(); + const existing = await ctx.db + .query("skillsShMirrorDigests") + .withIndex("by_external_id", (q) => q.eq("externalId", EXTERNAL_ID)) + .unique(); + if (existing) { + assertOwnedDigest(existing); + const run = await ctx.db.get("skillsShMirrorRuns", existing.lastObservedRunId); + if (!run) throw new Error("CLAW-577 Test fixture is missing its owned run"); + assertOwnedRun(run); + return { ok: true as const, digestId: existing._id, runId: run._id, created: false }; + } + if (await findOwnedRun(ctx)) throw new Error("CLAW-577 Test fixture has partial state"); + + const now = Date.now(); + const runId = await ctx.db.insert("skillsShMirrorRuns", { + snapshotId: SNAPSHOT_ID, + sourceView: "leaderboard", + sourceSnapshotHash: SNAPSHOT_HASH, + sourceCaptureWrites: 0, + status: "completed", + sourceTotal: 1, + sourcePageSize: 1, + sourceMeasuredAt: new Date(now).toISOString(), + sourceDurationMs: 0, + page: 1, + offset: 0, + counts: { + observed: 1, + inserted: 1, + updated: 0, + unchanged: 0, + rejected: 0, + quarantined: 0, + quarantinedPreserved: 0, + conflicts: 0, + detailsInserted: 0, + detailsUpdated: 0, + detailsUnchanged: 0, + detailsMissing: 1, + detailsTruncated: 0, + tombstoned: 0, + reactivated: 0, + scansPlanned: 0, + scansAdmitted: 0, + }, + operations: { + functionCalls: 1, + dbReads: 1, + dbWrites: 2, + sourceRequests: 0, + sourceBytes: 0, + }, + actor: ACTOR, + reason: "Owned synthetic row for CLAW-577 permanent-Test search order proof.", + startedAt: now, + completedAt: now, + updatedAt: now, + }); + const digestId = await ctx.db.insert("skillsShMirrorDigests", { + externalId: EXTERNAL_ID, + sourceType: "github", + upstreamSourceType: "github", + owner: "clawhub-test", + repo: "claw-577", + slug: "search-popularity-decoy", + normalizedSlug: "search popularity decoy", + normalizedSlugFirstToken: "search", + displayName: "Search Popularity Decoy", + normalizedDisplayName: "search popularity decoy", + normalizedDisplayNameFirstToken: "search", + searchSummary: "Gifgrep search decoy with deliberately irrelevant lifetime popularity.", + searchText: + "Search Popularity Decoy search-popularity-decoy gifgrep search animated gifs irrelevant lifetime popularity", + sourceUrl: `https://skills.sh/${EXTERNAL_ID}`, + canonicalRepoUrl: "https://github.com/clawhub-test/claw-577", + githubPath: "skills/search-popularity-decoy", + githubCommit: "0000000000000000000000000000000000000000", + upstreamInstalls: 9_000_000, + upstreamScanners: { + genAgentTrustHub: { status: "unavailable" }, + socket: { status: "unavailable" }, + snyk: { status: "unavailable" }, + }, + inferredCategories: ["search"], + inferredTopics: ["gif-search"], + sourceFreshnessStatus: "observed-only", + detailStatus: "missing", + observationFingerprint: SNAPSHOT_ID, + sourceSnapshotId: SNAPSHOT_ID, + lastObservedRunId: runId, + active: true, + publicVisible: true, + installable: true, + firstObservedAt: now, + lastObservedAt: now, + createdAt: now, + updatedAt: now, + }); + return { ok: true as const, digestId, runId, created: true }; + }, +}); + +export const readCanonicalSearchTestFixture = internalQuery({ + args: confirmArgs, + handler: async (ctx) => { + assertTestSeedAllowed(); + const fixture = await readOwnedFixture(ctx); + if (!fixture) return { present: false as const }; + return { + present: true as const, + externalId: fixture.digest.externalId, + digestId: fixture.digest._id, + runId: fixture.run._id, + upstreamInstalls: fixture.digest.upstreamInstalls, + publicVisible: fixture.digest.publicVisible, + installable: fixture.digest.installable, + scansPlanned: fixture.run.counts.scansPlanned, + scansAdmitted: fixture.run.counts.scansAdmitted, + }; + }, +}); + +export const cleanupCanonicalSearchTestFixture = internalMutation({ + args: { + ...confirmArgs, + digestId: v.id("skillsShMirrorDigests"), + runId: v.id("skillsShMirrorRuns"), + }, + handler: async (ctx, args) => { + assertTestSeedAllowed(); + const [digest, run] = await Promise.all([ + ctx.db.get("skillsShMirrorDigests", args.digestId), + ctx.db.get("skillsShMirrorRuns", args.runId), + ]); + if (!digest && !run) { + const [replacement, replacementRun] = await Promise.all([ + ctx.db + .query("skillsShMirrorDigests") + .withIndex("by_external_id", (q) => q.eq("externalId", EXTERNAL_ID)) + .unique(), + findOwnedRun(ctx), + ]); + if (replacement || replacementRun) { + throw new Error("CLAW-577 newer fixture occupies the owned identity"); + } + return { ok: true as const, removed: false as const }; + } + if (!digest || !run) throw new Error("CLAW-577 Test fixture cleanup found partial state"); + assertOwnedDigest(digest); + assertOwnedRun(run); + if (digest._id !== args.digestId || digest.lastObservedRunId !== args.runId) { + throw new Error("CLAW-577 Test fixture cleanup ID ownership mismatch"); + } + + const [details, facets, conflicts] = await Promise.all([ + ctx.db + .query("skillsShMirrorDetails") + .withIndex("by_digest_id", (q) => q.eq("digestId", args.digestId)) + .take(1), + ctx.db + .query("skillsShMirrorFacets") + .withIndex("by_digest_id_and_kind_and_term", (q) => q.eq("digestId", args.digestId)) + .take(1), + ctx.db + .query("skillsShMirrorConflicts") + .withIndex("by_run_id", (q) => q.eq("runId", args.runId)) + .take(1), + ]); + if (details.length > 0 || facets.length > 0 || conflicts.length > 0) { + throw new Error("CLAW-577 Test fixture cleanup refused dependent rows"); + } + + await ctx.db.delete("skillsShMirrorDigests", args.digestId); + await ctx.db.delete("skillsShMirrorRuns", args.runId); + return { ok: true as const, removed: true as const }; + }, +}); diff --git a/convex/skillsShMirror.test.ts b/convex/skillsShMirror.test.ts index f0c51673..c4d92111 100644 --- a/convex/skillsShMirror.test.ts +++ b/convex/skillsShMirror.test.ts @@ -1055,6 +1055,8 @@ describe("skills.sh external mirror", () => { normalizedSlugFirstToken: "find", normalizedDisplayName: "find skills", normalizedDisplayNameFirstToken: "find", + searchSummary: "# Find Skills", + searchText: expect.stringContaining("# Find Skills"), upstreamScanners: githubRow.upstreamScanners, inferredCategories: ["development"], inferredTopics: ["skill-discovery"], diff --git a/convex/skillsShMirror.ts b/convex/skillsShMirror.ts index e90196fb..e5181e6f 100644 --- a/convex/skillsShMirror.ts +++ b/convex/skillsShMirror.ts @@ -415,11 +415,17 @@ function prefixUpperBound(prefix: string) { function searchFields(row: MirrorRow) { const normalizedSlug = normalizedSearchText(row.slug); const normalizedDisplayName = normalizedSearchText(row.displayName); + const searchSummary = row.detail?.content + .replace(/^---[\s\S]*?---\s*/u, "") + .replace(/\s+/gu, " ") + .trim() + .slice(0, 512); return { normalizedSlug, normalizedSlugFirstToken: firstSearchToken(row.slug), normalizedDisplayName, normalizedDisplayNameFirstToken: firstSearchToken(row.displayName), + ...(searchSummary ? { searchSummary } : {}), searchText: [ row.displayName, row.slug, @@ -428,6 +434,7 @@ function searchFields(row: MirrorRow) { row.sourceHost, ...row.inferredCategories, ...row.inferredTopics, + searchSummary, ] .filter((value): value is string => Boolean(value)) .join(" "), diff --git a/package.json b/package.json index 0e373ff2..4631a648 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "publish:prepublication-worker": "bun scripts/security/run-prepublication-worker.ts", "release:clawhub:cli:changelog": "node scripts/extract-changelog-release.mjs", "release:clawhub:cli:npm:check": "node scripts/clawhub-cli-npm-release-check.mjs", + "search:prove-test": "bun scripts/canonical-search/prove-test.ts", "security:codex-worker": "bun scripts/security/run-codex-scan-worker.ts", "seed": "bun scripts/seed.ts", "seed:dev": "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts --seed-only", diff --git a/packages/clawhub/src/cli/commands/skills.test.ts b/packages/clawhub/src/cli/commands/skills.test.ts index 237c5a5b..1a7fbf5d 100644 --- a/packages/clawhub/src/cli/commands/skills.test.ts +++ b/packages/clawhub/src/cli/commands/skills.test.ts @@ -348,6 +348,52 @@ describe("cmdSearch", () => { ); expect(mockLog).toHaveBeenCalledWith("legacy Legacy Owner Legacy Skill 1 download"); }); + + it("preserves canonical mixed API order and prints installable external references", async () => { + mockGetOptionalAuthToken.mockResolvedValue(undefined); + mockApiRequest.mockResolvedValue({ + results: [ + { + id: "skills-sh:acme/skills/calendar", + source: "skills-sh", + slug: "calendar", + displayName: "External Calendar", + score: 6_110, + ownerHandle: "acme", + install: { + kind: "skills-sh", + reference: "skills-sh/acme/skills/calendar", + sourceUrl: "https://skills.sh/acme/skills/calendar", + }, + sourceIdentity: { + id: "acme/skills/calendar", + owner: "acme", + repo: "skills", + host: null, + lifetimeInstalls: 99_000, + }, + }, + { + id: "clawhub:skills:calendar", + source: "clawhub", + slug: "calendar-native", + displayName: "Native Calendar", + score: 5_095, + ownerHandle: "openclaw", + metrics: { rolling60DayInstalls: 12, bookmarks: 3, updatedAt: 1 }, + }, + ], + }); + + await cmdSearch(makeOpts(), "calendar"); + + const lines = mockLog.mock.calls.map(([line]) => String(line)); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain("skills-sh/acme/skills/calendar"); + expect(lines[0]).toContain("99,000 skills.sh lifetime installs"); + expect(lines[1]).toContain("calendar-native"); + expect(lines[1]).toContain("12 installs / 60d"); + }); }); describe("skill moderation commands", () => { diff --git a/packages/clawhub/src/cli/commands/skills.ts b/packages/clawhub/src/cli/commands/skills.ts index 5218a28f..48bc4b2b 100644 --- a/packages/clawhub/src/cli/commands/skills.ts +++ b/packages/clawhub/src/cli/commands/skills.ts @@ -275,10 +275,17 @@ function formatPinnedDetails(entry?: { pinReason?: string }) { function formatSearchOwner(entry: { ownerHandle?: string | null; owner?: { handle?: string | null; displayName?: string | null } | null; + publisher?: { handle?: string | null; displayName?: string | null } | null; + sourceIdentity?: { owner?: string | null; host?: string | null }; }) { - const handle = entry.ownerHandle ?? entry.owner?.handle; + const handle = + entry.publisher?.handle ?? + entry.ownerHandle ?? + entry.owner?.handle ?? + entry.sourceIdentity?.owner ?? + entry.sourceIdentity?.host; if (handle) return `@${handle}`; - return entry.owner?.displayName ?? "unknown owner"; + return entry.publisher?.displayName ?? entry.owner?.displayName ?? "unknown owner"; } export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) { @@ -302,7 +309,12 @@ export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) const rows = result.results.map((entry) => { const slug = entry.slug ?? "unknown"; return { - slug: entry.version ? `${slug} v${entry.version}` : slug, + slug: + entry.source === "skills-sh" && entry.install?.reference + ? entry.install.reference + : entry.version + ? `${slug} v${entry.version}` + : slug, owner: formatSearchOwner(entry), name: entry.displayName ?? slug, metric: formatSearchMetric(entry), @@ -329,7 +341,20 @@ function maxColumnWidth(values: string[]) { return values.reduce((max, value) => Math.max(max, value.length), 0); } -function formatSearchMetric(entry: { downloads?: number; score: number }) { +function formatSearchMetric(entry: { + downloads?: number; + score: number; + metrics?: { rolling60DayInstalls?: number | null }; + sourceIdentity?: { lifetimeInstalls?: number | null }; +}) { + if (typeof entry.metrics?.rolling60DayInstalls === "number") { + const value = new Intl.NumberFormat("en-US").format(entry.metrics.rolling60DayInstalls); + return `${value} installs / 60d`; + } + if (typeof entry.sourceIdentity?.lifetimeInstalls === "number") { + const value = new Intl.NumberFormat("en-US").format(entry.sourceIdentity.lifetimeInstalls); + return `${value} skills.sh lifetime installs`; + } if (typeof entry.downloads === "number") { const value = new Intl.NumberFormat("en-US").format(entry.downloads); return `${value} ${entry.downloads === 1 ? "download" : "downloads"}`; diff --git a/packages/clawhub/src/schema/schemas.test.ts b/packages/clawhub/src/schema/schemas.test.ts index 131b36c2..c65e540c 100644 --- a/packages/clawhub/src/schema/schemas.test.ts +++ b/packages/clawhub/src/schema/schemas.test.ts @@ -59,6 +59,70 @@ describe("packages/clawhub skill metadata schema", () => { expect(parsed.results[0]?.owner?.displayName).toBe("OpenClaw"); }); + it("preserves canonical mixed search order and source/trust metadata", () => { + const parsed = parseArk( + ApiV1SearchResponseSchema, + { + results: [ + { + id: "skills-sh:acme/skills/calendar", + source: "skills-sh", + slug: "calendar", + displayName: "Calendar", + summary: "Calendar workflows", + score: 6_110, + canonicalUrl: "/skills-sh/acme/skills/calendar", + official: false, + featured: false, + links: { + canonical: "/skills-sh/acme/skills/calendar", + source: "https://skills.sh/acme/skills/calendar", + }, + publisher: null, + install: { + kind: "skills-sh", + reference: "skills-sh/acme/skills/calendar", + sourceUrl: "https://skills.sh/acme/skills/calendar", + }, + sourceIdentity: { + id: "acme/skills/calendar", + owner: "acme", + repo: "skills", + host: null, + lifetimeInstalls: 99_000, + }, + trust: { + visibility: "public", + installability: "installable", + clawHubVerdict: null, + upstreamScanners: { socket: { status: "pass" } }, + sourceFreshness: "observed-only", + }, + metrics: { + rolling60DayInstalls: null, + bookmarks: null, + updatedAt: 10, + }, + }, + { + id: "clawhub:skills:calendar", + source: "clawhub", + slug: "calendar-native", + score: 5_095, + }, + ], + }, + "Search", + ); + + expect(parsed.results.map((result) => result.id)).toEqual([ + "skills-sh:acme/skills/calendar", + "clawhub:skills:calendar", + ]); + expect(parsed.results[0]?.install?.reference).toBe("skills-sh/acme/skills/calendar"); + expect(parsed.results[0]?.trust?.sourceFreshness).toBe("observed-only"); + }); + it("parses pending package publish responses with legacy IDs", () => { const parsed = parseArk( ApiV1PackagePublishResponseSchema, diff --git a/packages/clawhub/src/schema/schemas.ts b/packages/clawhub/src/schema/schemas.ts index 026416d2..1921572e 100644 --- a/packages/clawhub/src/schema/schemas.ts +++ b/packages/clawhub/src/schema/schemas.ts @@ -406,6 +406,8 @@ export type ApiV1StaffEmailSendResponse = (typeof ApiV1StaffEmailSendResponseSch export const ApiV1SearchResponseSchema = type({ results: type({ + "id?": "string", + "source?": '"clawhub"|"skills-sh"', slug: "string?", ownerHandle: "string|null?", displayName: "string?", @@ -418,9 +420,49 @@ export const ApiV1SearchResponseSchema = type({ handle: "string|null?", displayName: "string|null?", image: "string|null?", + "kind?": '"user"|"org"', + "official?": "boolean", }) .or("null") .optional(), + "canonicalUrl?": "string", + "official?": "boolean", + "featured?": "boolean", + "links?": { + canonical: "string", + source: "string|null", + }, + "publisher?": type({ + kind: '"user"|"org"', + handle: "string|null", + displayName: "string|null", + image: "string|null", + official: "boolean", + }).or("null"), + "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: "unknown|null", + sourceFreshness: '"native"|"observed-only"', + }, + "metrics?": { + rolling60DayInstalls: "number|null", + bookmarks: "number|null", + updatedAt: "number", + }, }).array(), }); diff --git a/scripts/canonical-search/proof-contract.test.ts b/scripts/canonical-search/proof-contract.test.ts new file mode 100644 index 00000000..1773625a --- /dev/null +++ b/scripts/canonical-search/proof-contract.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + assertCanonicalSearchResults, + assertStableOrder, + latencySummary, + orderedResultIds, +} from "./proof-contract"; + +const native = { + id: "clawhub:skill-1", + source: "clawhub", + canonicalUrl: "/publisher/skills/gifgrep", + links: { canonical: "/publisher/skills/gifgrep", source: null }, + publisher: { + kind: "org", + handle: "publisher", + displayName: "Publisher", + image: null, + official: true, + }, + official: true, + featured: false, + install: { kind: "clawhub", reference: "publisher/gifgrep", sourceUrl: null }, + sourceIdentity: { + id: "skill-1", + owner: "publisher", + repo: null, + host: null, + lifetimeInstalls: null, + }, + trust: { + visibility: "public", + installability: "installable", + clawHubVerdict: "clean", + upstreamScanners: null, + sourceFreshness: "native", + }, + metrics: { rolling60DayInstalls: 12, bookmarks: 3, updatedAt: 10 }, +}; + +const external = { + id: "skills-sh:clawhub-test/claw-577/search-popularity-decoy", + source: "skills-sh", + canonicalUrl: "/skills-sh/clawhub-test/claw-577/search-popularity-decoy", + links: { + canonical: "/skills-sh/clawhub-test/claw-577/search-popularity-decoy", + source: "https://skills.sh/clawhub-test/claw-577/search-popularity-decoy", + }, + publisher: null, + official: false, + featured: false, + install: { + kind: "skills-sh", + reference: "skills-sh/clawhub-test/claw-577/search-popularity-decoy", + sourceUrl: "https://skills.sh/clawhub-test/claw-577/search-popularity-decoy", + }, + sourceIdentity: { + id: "clawhub-test/claw-577/search-popularity-decoy", + owner: "clawhub-test", + repo: "claw-577", + host: null, + lifetimeInstalls: 9_000_000, + }, + trust: { + visibility: "public", + installability: "installable", + clawHubVerdict: null, + upstreamScanners: {}, + sourceFreshness: "observed-only", + }, + metrics: { rolling60DayInstalls: null, bookmarks: null, updatedAt: 11 }, +}; + +describe("canonical search Test proof contract", () => { + it("validates canonical native and external result metadata", () => { + expect(assertCanonicalSearchResults([native, external])).toEqual([native, external]); + expect(orderedResultIds([native, external])).toEqual([native.id, external.id]); + }); + + it("rejects consumer order drift", () => { + expect(() => + assertStableOrder([ + [native.id, external.id], + [native.id, external.id], + ]), + ).not.toThrow(); + expect(() => + assertStableOrder([ + [native.id, external.id], + [external.id, native.id], + ]), + ).toThrow("search order drifted"); + }); + + it("records median and nearest-rank p95 without inventing a latency SLO", () => { + expect(latencySummary([40, 10, 30, 20, 50])).toEqual({ medianMs: 30, p95Ms: 50 }); + }); +}); diff --git a/scripts/canonical-search/proof-contract.ts b/scripts/canonical-search/proof-contract.ts new file mode 100644 index 00000000..29a5833a --- /dev/null +++ b/scripts/canonical-search/proof-contract.ts @@ -0,0 +1,78 @@ +export type CanonicalSearchResult = Record & { + id: string; + source: "clawhub" | "skills-sh"; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function requireRecord(value: unknown, field: string) { + if (!isRecord(value)) throw new Error(`canonical search result is missing ${field}`); + return value; +} + +function requireString(value: unknown, field: string) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`canonical search result is missing ${field}`); + } + return value; +} + +export function assertCanonicalSearchResults(results: unknown[]): CanonicalSearchResult[] { + return results.map((value) => { + const result = requireRecord(value, "result"); + const id = requireString(result.id, "id"); + if (result.source !== "clawhub" && result.source !== "skills-sh") { + throw new Error(`canonical search result ${id} has invalid source`); + } + const canonicalUrl = requireString(result.canonicalUrl, "canonicalUrl"); + const links = requireRecord(result.links, "links"); + if (links.canonical !== canonicalUrl) { + throw new Error(`canonical search result ${id} has inconsistent canonical links`); + } + if (typeof result.official !== "boolean" || typeof result.featured !== "boolean") { + throw new Error(`canonical search result ${id} is missing official/featured metadata`); + } + const install = requireRecord(result.install, "install"); + requireString(install.reference, "install.reference"); + requireRecord(result.sourceIdentity, "sourceIdentity"); + const trust = requireRecord(result.trust, "trust"); + if (trust.visibility !== "public" || trust.installability !== "installable") { + throw new Error(`canonical search result ${id} is not public and installable`); + } + requireRecord(result.metrics, "metrics"); + if (result.source === "clawhub" && !isRecord(result.publisher)) { + throw new Error(`canonical native search result ${id} is missing publisher metadata`); + } + if (result.source === "skills-sh" && result.publisher !== null) { + throw new Error(`canonical external search result ${id} must not invent a publisher`); + } + return result as CanonicalSearchResult; + }); +} + +export function orderedResultIds(results: unknown[]) { + return assertCanonicalSearchResults(results).map((result) => result.id); +} + +export function assertStableOrder(orders: string[][]) { + const expected = JSON.stringify(orders[0] ?? []); + if (orders.some((order) => JSON.stringify(order) !== expected)) { + throw new Error("canonical search order drifted across requests or consumer surfaces"); + } +} + +function nearestRank(values: number[], percentile: number) { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.max(0, Math.ceil(percentile * sorted.length) - 1); + return sorted[index] ?? 0; +} + +export function latencySummary(values: number[]) { + if (values.length === 0) throw new Error("latency sample is empty"); + return { + medianMs: nearestRank(values, 0.5), + p95Ms: nearestRank(values, 0.95), + }; +} diff --git a/scripts/canonical-search/prove-test.ts b/scripts/canonical-search/prove-test.ts new file mode 100644 index 00000000..63d2e875 --- /dev/null +++ b/scripts/canonical-search/prove-test.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env bun + +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { promisify } from "node:util"; +import { CANONICAL_SKILL_SEARCH_BOUNDS } from "../../convex/lib/canonicalSkillSearchBounds"; +import { + assertCanonicalSearchResults, + assertStableOrder, + latencySummary, + orderedResultIds, + type CanonicalSearchResult, +} from "./proof-contract"; + +const OUTPUT_PATH = resolve("proof/claw-577/canonical-search-test-proof.json"); +const EXTERNAL_ID = "clawhub-test/claw-577/search-popularity-decoy"; +const SAMPLE_COUNT = 3; +const LIMIT = 100; + +function requireEnv(name: string) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +const deploySha = requireEnv("DEPLOY_SHA"); +const siteUrl = requireEnv("TEST_SITE_URL").replace(/\/$/, ""); +const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim(); + +type TimedResults = { elapsedMs: number; results: CanonicalSearchResult[] }; +const execFileAsync = promisify(execFile); + +async function fetchSearch(path: string, query: string): Promise { + const url = new URL(path, siteUrl); + url.searchParams.set("q", query); + url.searchParams.set("limit", String(LIMIT)); + const startedAt = performance.now(); + const response = await fetch(url, { + headers: bypass ? { "x-vercel-protection-bypass": bypass } : undefined, + }); + const elapsedMs = performance.now() - startedAt; + const text = await response.text(); + if (!response.ok) throw new Error(`${path} returned HTTP ${response.status}: ${text}`); + const payload = JSON.parse(text) as { results?: unknown }; + if (!Array.isArray(payload.results)) throw new Error(`${path} response is missing results`); + return { elapsedMs, results: assertCanonicalSearchResults(payload.results) }; +} + +async function runActionSearch(query: string): Promise { + const startedAt = performance.now(); + const { stdout } = await execFileAsync( + "bunx", + ["convex", "run", "--no-push", "search:searchSkills", JSON.stringify({ query, limit: LIMIT })], + { env: process.env, maxBuffer: 16 * 1024 * 1024 }, + ); + const results = JSON.parse(stdout.trim()) as unknown; + if (!Array.isArray(results)) throw new Error("search action response is missing results"); + return { + elapsedMs: performance.now() - startedAt, + results: assertCanonicalSearchResults(results), + }; +} + +async function sampleSurface(load: () => Promise) { + const samples: TimedResults[] = []; + for (let index = 0; index < SAMPLE_COUNT; index += 1) samples.push(await load()); + assertStableOrder(samples.map((sample) => orderedResultIds(sample.results))); + return samples; +} + +async function proveCase(name: string, query: string) { + const v1 = await sampleSurface(() => fetchSearch("/api/v1/search", query)); + const legacy = await sampleSurface(() => fetchSearch("/api/search", query)); + const action = await sampleSurface(() => runActionSearch(query)); + const orders = [ + ...v1.map((sample) => orderedResultIds(sample.results)), + ...legacy.map((sample) => orderedResultIds(sample.results)), + ...action.map((sample) => orderedResultIds(sample.results)), + ]; + assertStableOrder(orders); + return { + name, + query, + orderedIds: orders[0], + results: v1[0]?.results ?? [], + latency: { + apiV1: latencySummary(v1.map((sample) => sample.elapsedMs)), + compatibilityApi: latencySummary(legacy.map((sample) => sample.elapsedMs)), + webAction: latencySummary(action.map((sample) => sample.elapsedMs)), + }, + }; +} + +await fetchSearch("/api/v1/search", "gifgrep"); +const discovery = await fetchSearch("/api/v1/search", "gifgrep"); +const nativeGifgrep = discovery.results.find( + (result) => result.source === "clawhub" && result.slug === "gifgrep", +); +if (!nativeGifgrep || typeof nativeGifgrep.ownerHandle !== "string") { + throw new Error("permanent Test is missing the stable native gifgrep fixture"); +} + +const cases = []; +cases.push(await proveCase("exact-native-and-irrelevant-popularity", "gifgrep")); +cases.push(await proveCase("owner-qualified-native", `${nativeGifgrep.ownerHandle}/gifgrep`)); +cases.push(await proveCase("exact-external", `skills-sh/${EXTERNAL_ID}`)); +cases.push(await proveCase("natural-language-intent", "stock price")); + +const exact = cases[0]; +const exactFirst = exact?.results[0]; +if (exactFirst?.id !== nativeGifgrep.id) { + throw new Error("exact native match did not remain first"); +} +const decoyIndex = exact.results.findIndex((result) => result.id === `skills-sh:${EXTERNAL_ID}`); +if (decoyIndex <= 0) + throw new Error("9M-lifetime external decoy did not remain below exact native"); +const decoy = exact.results[decoyIndex]; +const decoyIdentity = decoy?.sourceIdentity as Record | undefined; +if (decoyIdentity?.lifetimeInstalls !== 9_000_000) { + throw new Error("external popularity decoy lifetime install count is missing"); +} +if (cases[1]?.results[0]?.id !== nativeGifgrep.id) { + throw new Error("owner-qualified native lookup did not preserve exact identity order"); +} +if (cases[2]?.results[0]?.id !== `skills-sh:${EXTERNAL_ID}`) { + throw new Error("owner-qualified external lookup did not return the exact external identity"); +} +if ((cases[3]?.results.length ?? 0) === 0) { + throw new Error("natural-language intent query returned no results"); +} + +const proof = { + generatedAt: new Date().toISOString(), + target: { + environment: "permanent Test", + deploySha, + siteUrl, + convexDeployment: "academic-chihuahua-392", + productionWrites: 0, + schedulesCreated: 0, + scansPlanned: 0, + scansAdmitted: 0, + claimsCreated: 0, + }, + contract: { + samplesPerSurface: SAMPLE_COUNT, + consumerSurfaces: ["api-v1-cli-openclaw", "compatibility-api", "clawhub-web-action"], + costBounds: { + ...CANONICAL_SKILL_SEARCH_BOUNDS, + maximumExternalIndexedCandidates: + CANONICAL_SKILL_SEARCH_BOUNDS.externalCandidateLimitPerIndex * + CANONICAL_SKILL_SEARCH_BOUNDS.externalIndexedReadCount, + measurement: "source-enforced candidate bounds; document-read counters are not exposed", + }, + latencyPolicy: "observed median/p95 recorded; issue defines no numeric latency SLO", + }, + fixture: { + externalId: EXTERNAL_ID, + lifetimeInstalls: 9_000_000, + rankingWeight: 0, + }, + cases, +}; + +await mkdir(dirname(OUTPUT_PATH), { recursive: true }); +await writeFile(OUTPUT_PATH, `${JSON.stringify(proof, null, 2)}\n`); +console.log(JSON.stringify({ ok: true, outputPath: OUTPUT_PATH, cases: cases.length })); diff --git a/specs/github-backed-skills.md b/specs/github-backed-skills.md index 6aa51924..cd812606 100644 --- a/specs/github-backed-skills.md +++ b/specs/github-backed-skills.md @@ -107,12 +107,15 @@ controls, durable run cursors, and conflicts remain in their own mirror tables. - GitHub identities are exact `owner/repo/slug` values. Well-known identities are exact `sourceHost/slug` values and must not invent a repository owner. -- Every digest is permanently `publicVisible: false` and `installable: false`. - Mirror ingestion never creates native skills, publisher attachment, claims, - scan plans, or scan jobs. -- The digest stores normalized slug/display-name fields and a lean - `searchText`. Exact, prefix, first-token, popularity, freshness, and - full-text indexes are staged before activation on the permanent Test corpus. +- Mirror ingestion always writes `publicVisible: false` and `installable: false`. + A separately accepted activation flow may opt an exact row into public search + and install surfaces; canonical search requires both flags and fails closed + when either is absent. Mirror ingestion never creates native skills, + publisher attachment, claims, scan plans, or scan jobs. +- The digest stores normalized slug/display-name fields, a bounded content + summary, and lean `searchText`. Exact, prefix, first-token, freshness, and + full-text indexes feed canonical relevance-first search. Upstream popularity + is presentation metadata only and has zero search-ranking weight. - Gen Agent Trust Hub, Socket, and Snyk observations are stored independently with a bounded status plus optional source timestamp and source link. These are upstream claims only and must never be serialized as a ClawHub verdict. diff --git a/specs/search-relevance.md b/specs/search-relevance.md index 41812f05..e93507f6 100644 --- a/specs/search-relevance.md +++ b/specs/search-relevance.md @@ -6,7 +6,7 @@ ClawHub search is a retrieval surface, not a browse fallback. A package, plugin, - exact or token-prefix match in taxonomy fields such as categories and author topics; - token-prefix match in exploratory fields such as summary, using a minimum query-token length for every query token to avoid short-query noise. -Trust and business signals are not relevance signals. `official`, verification tier, security status, downloads, stars, installs, highlighting, and recency may break ties between already eligible matches or appear as filters/badges, but they must not make an otherwise unrelated item eligible for search. +Trust and business signals are not relevance signals. They must not make an otherwise unrelated item eligible for search. Package/plugin search may use its documented adoption tie-breaks; canonical mixed skill search follows the stricter source-neutral contract below. Generic fallback categories such as `other` are browse groupings, not search evidence. @@ -19,6 +19,19 @@ Search ranking should be lexicographic before it is numeric: Numeric scores, trust state, popularity, and recency may order results inside those broad tiers, but must not make a weaker-evidence match eligible for a stronger tier. +## Canonical mixed skill search + +`search.searchSkills` is the one ordered search contract for native ClawHub skills and publicly activated skills.sh mirror rows. It is separate from browse recommendation and Trending. + +- Candidate retrieval is bounded and indexed. Native lexical/vector recall and external exact, prefix, first-token, and full-text recall are merged before one final ordering pass; no full-corpus scan is allowed. +- Shared relevance tiers are exact identity/name, exact token, prefix token, taxonomy, summary/content, then semantic-only recall. Semantic recall can add a candidate but can never displace a lexical candidate from a stronger tier. +- Official and Featured may reorder only candidates with the same relevance evidence. Within comparable relevance, use ClawHub-observed rolling 60-day installs, then rolling Bookmarks and freshness. +- Lifetime ClawHub downloads, lifetime OpenClaw installs, skills.sh lifetime installs, GitHub stars, scanner status, and cross-source percentile normalization have zero ordering weight. They may still be returned as clearly labeled metadata. +- Native public-browse/installability checks and external `active && publicVisible && installable && observed-only && !tombstoned` checks run before ranking. Upstream scanner observations remain source metadata, not a ClawHub verdict. +- The canonical result includes a ClawHub route, source link, publisher/official metadata, install reference, source identity, trust metadata, rolling metrics, and the native rendering payload when applicable. HTTP, CLI, and web consumers preserve the action order by default. + +External install references use `skills-sh///` and their canonical ClawHub routes use `/skills-sh///`. + ## Exact-Match Squat Gate The exact-match tier is authority, and authority must be earned (issue #3054). An exact full-field diff --git a/specs/spec.md b/specs/spec.md index 829f65a5..1b044f11 100644 --- a/specs/spec.md +++ b/specs/spec.md @@ -62,8 +62,9 @@ read_when: compatibility. - Public Downloads are `statsDownloads` for native-only skills and `statsDownloads + statsSkillsShInstalls` for skills.sh-indexed skills. -- Search ranking continues to use native counters rather than the combined - presentation value. +- Canonical mixed skill search never ranks by this combined presentation value. + It uses lexical/semantic relevance first, then ClawHub-observed rolling + 60-day installs, rolling Bookmarks, and freshness for comparable matches. - `createdAt`, `updatedAt` ### SkillVersion diff --git a/src/__tests__/deploy-test-workflow.test.ts b/src/__tests__/deploy-test-workflow.test.ts index fb248657..6a2e56ad 100644 --- a/src/__tests__/deploy-test-workflow.test.ts +++ b/src/__tests__/deploy-test-workflow.test.ts @@ -8,11 +8,14 @@ type WorkflowStep = { if?: string; name?: string; run?: string; + uses?: string; + with?: Record; }; type WorkflowJob = { environment?: { name?: string; url?: string }; if?: string; + needs?: string; steps?: WorkflowStep[]; }; @@ -64,6 +67,8 @@ describe("Test deploy workflow", () => { expect(job?.if).toContain("inputs.branch_test_confirm == 'deploy-claw-563-to-permanent-test'"); expect(job?.if).toContain("github.ref == 'refs/heads/pe/claw-589-trending-rank-overlay'"); expect(job?.if).toContain("inputs.branch_test_confirm == 'deploy-claw-589-to-permanent-test'"); + expect(job?.if).toContain("github.ref == 'refs/heads/pe/claw-577-canonical-mixed-search'"); + expect(job?.if).toContain("inputs.branch_test_confirm == 'deploy-claw-577-to-permanent-test'"); expect(job?.if).toContain("inputs.expected_sha != ''"); expect(job?.if).toContain("github.event_name == 'pull_request'"); expect(job?.if).toContain( @@ -80,6 +85,12 @@ describe("Test deploy workflow", () => { expect(job?.if).toContain( "contains(github.event.pull_request.labels.*.name, 'test-trending-load')", ); + expect(job?.if).toContain( + "github.event.pull_request.head.ref == 'pe/claw-577-canonical-mixed-search'", + ); + expect(job?.if).toContain( + "contains(github.event.pull_request.labels.*.name, 'test-search-load')", + ); expect(job?.if).toContain("github.event.workflow_run.conclusion == 'success'"); expect(job?.if).toContain("github.event.workflow_run.event == 'push'"); expect(revision).toContain('deploy_sha" != "$main_sha'); @@ -88,6 +99,8 @@ describe("Test deploy workflow", () => { expect(revision).toContain("deploy-claw-563-to-permanent-test"); expect(revision).toContain("refs/heads/pe/claw-589-trending-rank-overlay"); expect(revision).toContain("deploy-claw-589-to-permanent-test"); + expect(revision).toContain("refs/heads/pe/claw-577-canonical-mixed-search"); + expect(revision).toContain("deploy-claw-577-to-permanent-test"); expect(revision).toContain("${{ inputs.expected_sha }}"); expect(revision).toContain("${{ github.event.pull_request.head.sha }}"); expect(revision).toContain("${{ github.event.pull_request.head.repo.full_name }}"); @@ -213,4 +226,38 @@ describe("Test deploy workflow", () => { expect(run).toContain("trending24hInstalls == null"); expect(run).toContain("revokedStatus"); }); + + it("runs the CLAW-577 search proof only for its exact guarded branch", async () => { + const workflow = await readWorkflow(); + const job = workflow.jobs?.["claw577-search-proof"]; + const proofStep = job?.steps?.find( + (candidate) => candidate.name === "Prove canonical search order and cost in permanent Test", + ); + const upload = job?.steps?.find( + (candidate) => candidate.name === "Upload permanent Test canonical search proof", + ); + const run = proofStep?.run ?? ""; + + expect(job?.needs).toBe("deploy-test"); + expect(job?.if).toContain( + "github.event.pull_request.head.ref == 'pe/claw-577-canonical-mixed-search'", + ); + expect(job?.if).toContain("test-search-load"); + expect(job?.if).toContain("inputs.branch_test_confirm == 'deploy-claw-577-to-permanent-test'"); + expect(job?.environment?.name).toBe("Test"); + expect(job?.steps?.[0]?.with?.ref).toContain("github.event.pull_request.head.sha"); + expect(proofStep?.env?.DEPLOY_SHA).toBe("${{ needs.deploy-test.outputs.deploy_sha }}"); + expect(run).toContain("appMeta:getDeploymentInfo"); + expect(run).toContain("searchTestFixtures:seedCanonicalSearchTestFixture"); + expect(run).toContain("bun run search:prove-test"); + expect(run).toContain("trap cleanup EXIT"); + expect(run).toContain("searchTestFixtures:cleanupCanonicalSearchTestFixture"); + expect(run).toContain("searchTestFixtures:readCanonicalSearchTestFixture"); + expect(run).toContain("claw577-cleanup-recovery.json"); + expect(upload?.uses).toBe("actions/upload-artifact@v7"); + expect(upload?.with?.name).toBe("claw577-search-proof"); + expect(upload?.with?.["if-no-files-found"]).toBe("error"); + expect(upload?.with?.path).toContain("proof/claw-577/canonical-search-test-proof.json"); + expect(upload?.with?.path).toContain("claw577-cleanup.json"); + }); }); diff --git a/src/__tests__/home-listing-section.test.tsx b/src/__tests__/home-listing-section.test.tsx index 3eda0bf9..bd24fcc9 100644 --- a/src/__tests__/home-listing-section.test.tsx +++ b/src/__tests__/home-listing-section.test.tsx @@ -40,7 +40,7 @@ vi.mock("../../convex/_generated/api", () => ({ listPublicTrendingPage: "skills:listPublicTrendingPage", }, search: { - searchSkills: "search:searchSkills", + searchNativeSkills: "search:searchNativeSkills", }, }, })); @@ -341,7 +341,7 @@ describe("HomeListingSection", () => { fireEvent.change(searchInput, { target: { value: "alpha" } }); await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", { + expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", { query: "alpha", limit: 20, }); @@ -380,7 +380,7 @@ describe("HomeListingSection", () => { fireEvent.change(searchInput, { target: { value: "alpha" } }); await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", { + expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", { query: "alpha", limit: 20, categorySlug: "development", @@ -615,7 +615,7 @@ describe("HomeListingSection", () => { fireEvent.change(screen.getByRole("searchbox"), { target: { value: "search" } }); await waitFor(() => { - expect(convexActionMock).toHaveBeenCalledWith("search:searchSkills", { + expect(convexActionMock).toHaveBeenCalledWith("search:searchNativeSkills", { query: "search", limit: 20, highlightedOnly: true, diff --git a/src/__tests__/skills-index.test.tsx b/src/__tests__/skills-index.test.tsx index a368957f..cf475b76 100644 --- a/src/__tests__/skills-index.test.tsx +++ b/src/__tests__/skills-index.test.tsx @@ -364,14 +364,13 @@ describe("SkillsIndex", () => { key: "japanese-conversation-scorer::0::::", limit: 25, results: [ - { - skill: makeListResult("japanese-conversation-scorer", "Japanese Conversation Scorer") - .skill, - version: null, - ownerHandle: "bianmaxingkong", - owner: null, - score: 1, - }, + makeSearchResult( + "japanese-conversation-scorer", + "Japanese Conversation Scorer", + 1, + 0, + "bianmaxingkong", + ), ], }; const actionFn = vi.fn().mockResolvedValue([]); @@ -680,7 +679,7 @@ describe("SkillsIndex", () => { expect(links[2]?.textContent).toContain("Skill C"); }); - it("uses relevance as default sort when searching", async () => { + it("preserves canonical API order for default relevance search", async () => { searchMock = { q: "notion" }; const actionFn = vi .fn() @@ -700,8 +699,32 @@ describe("SkillsIndex", () => { (node) => node.textContent, ); - expect(titles[0]).toBe("Older High Score"); - expect(titles[1]).toBe("Newer Low Score"); + expect(titles[0]).toBe("Newer Low Score"); + expect(titles[1]).toBe("Older High Score"); + }); + + it("renders external skills in the canonical mixed order", async () => { + searchMock = { q: "find skills" }; + convexReactMocks.useAction.mockReturnValue( + vi + .fn() + .mockResolvedValue([ + makeSearchResult("native-find", "Native Find", 6_000, 2_000), + makeExternalSearchResult("vercel-labs/skills/find-skills", "Find Skills", 5_000), + ]), + ); + vi.useFakeTimers(); + + render(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + const titles = Array.from(document.querySelectorAll(".skill-list-item-name")).map( + (node) => node.textContent, + ); + expect(titles).toEqual(["Native Find", "Find Skills"]); + expect(screen.getByText("skills.sh")).toBeTruthy(); }); it("includes results explicitly assigned to the selected category", async () => { @@ -1110,48 +1133,97 @@ function makeListResult( } function makeSearchResults(count: number) { - return Array.from({ length: count }, (_, index) => ({ - score: 0.9, - skill: { - _id: `skill_${index}`, - slug: `skill-${index}`, - displayName: `Skill ${index}`, - summary: `Summary ${index}`, - tags: {}, - stats: { - downloads: 0, - installs: 0, - stars: 0, - versions: 1, - comments: 0, - }, - createdAt: 0, - updatedAt: 0, - }, - version: null, - })); + return Array.from({ length: count }, (_, index) => + makeSearchResult(`skill-${index}`, `Skill ${index}`, 0.9, 0), + ); } -function makeSearchResult(slug: string, displayName: string, score: number, createdAt: number) { +function makeSearchResult( + slug: string, + displayName: string, + score: number, + createdAt: number, + ownerHandle: string | null = null, +) { + const skill = makeListResult(slug, displayName).skill; + skill.createdAt = createdAt; + skill.updatedAt = createdAt; return { + id: `clawhub:${skill._id}`, + source: "clawhub", + slug, + displayName, + summary: skill.summary, score, - skill: { - _id: `skill_${slug}`, - slug, - displayName, - summary: `${displayName} summary`, - tags: {}, - stats: { - downloads: 0, - installs: 0, - stars: 0, - versions: 1, - comments: 0, - }, - createdAt, - updatedAt: createdAt, + canonicalUrl: `/${ownerHandle ?? "owner"}/skills/${slug}`, + links: { + canonical: `/${ownerHandle ?? "owner"}/skills/${slug}`, + source: null, }, + official: false, + featured: false, + publisher: null, + install: { kind: "clawhub", reference: `${ownerHandle ?? "owner"}/${slug}`, sourceUrl: null }, + sourceIdentity: { + id: skill._id, + owner: ownerHandle, + repo: null, + host: null, + lifetimeInstalls: null, + }, + trust: { + visibility: "public", + installability: "installable", + clawHubVerdict: null, + upstreamScanners: null, + sourceFreshness: "native", + }, + metrics: { rolling60DayInstalls: 0, bookmarks: 0, updatedAt: createdAt }, + native: { skill, version: null, owner: null, ownerHandle }, + ownerHandle, version: null, + downloads: 0, + updatedAt: createdAt, + }; +} + +function makeExternalSearchResult(externalId: string, displayName: string, score: number) { + const slug = externalId.split("/").at(-1) ?? externalId; + const [owner, repo] = externalId.split("/"); + return { + id: `skills-sh:${externalId}`, + source: "skills-sh", + slug, + displayName, + summary: `${displayName} summary`, + score, + canonicalUrl: `/skills-sh/${externalId}`, + links: { + canonical: `/skills-sh/${externalId}`, + source: `https://skills.sh/${externalId}`, + }, + official: false, + featured: false, + publisher: null, + install: { + kind: "skills-sh", + reference: `skills-sh/${externalId}`, + sourceUrl: `https://skills.sh/${externalId}`, + }, + sourceIdentity: { id: externalId, owner, repo, host: null, lifetimeInstalls: 42 }, + trust: { + visibility: "public", + installability: "installable", + clawHubVerdict: null, + upstreamScanners: {}, + sourceFreshness: "observed-only", + }, + metrics: { rolling60DayInstalls: null, bookmarks: null, updatedAt: 1_000 }, + native: null, + ownerHandle: owner, + version: null, + downloads: null, + updatedAt: 1_000, }; } @@ -1161,24 +1233,7 @@ function makeSearchEntry(params: { stars: number; updatedAt: number; }) { - return { - score: 0.9, - skill: { - _id: `skill_${params.slug}`, - slug: params.slug, - displayName: params.displayName, - summary: `Summary ${params.slug}`, - tags: {}, - stats: { - downloads: 0, - installs: 0, - stars: params.stars, - versions: 1, - comments: 0, - }, - createdAt: 0, - updatedAt: params.updatedAt, - }, - version: null, - }; + const entry = makeSearchResult(params.slug, params.displayName, 0.9, params.updatedAt); + if (entry.native) entry.native.skill.stats.stars = params.stars; + return entry; } diff --git a/src/components/HomeListingSection.tsx b/src/components/HomeListingSection.tsx index 015141fa..a2e3a355 100644 --- a/src/components/HomeListingSection.tsx +++ b/src/components/HomeListingSection.tsx @@ -596,7 +596,7 @@ export function HomeListingSection({ initialListing = null }: HomeListingSection kind === "skills" ? Promise.all( (categorySlugs.length > 0 ? categorySlugs : [null]).map((categorySlug) => - convexHttp.action(api.search.searchSkills, { + convexHttp.action(api.search.searchNativeSkills, { query: trimmedSearch, limit: fetchLimit, highlightedOnly: tab === "featured" ? true : undefined, diff --git a/src/lib/useUnifiedSearch.ts b/src/lib/useUnifiedSearch.ts index 930f1d67..821619f4 100644 --- a/src/lib/useUnifiedSearch.ts +++ b/src/lib/useUnifiedSearch.ts @@ -95,7 +95,7 @@ export function useUnifiedSearch( activeType: UnifiedSearchType, options: UnifiedSearchOptions = {}, ) { - const searchSkills = useAction(api.search.searchSkills); + const searchSkills = useAction(api.search.searchNativeSkills); const requestRef = useRef(0); const debounceMs = options.debounceMs ?? 300; const enabled = options.enabled ?? true; diff --git a/src/routes/search.tsx b/src/routes/search.tsx index a1f6756c..aef2c796 100644 --- a/src/routes/search.tsx +++ b/src/routes/search.tsx @@ -46,7 +46,7 @@ async function loadInitialSearchResults(query: string | undefined) { if (!trimmed) return null; try { - const skillsRaw = (await convexHttp.action(api.search.searchSkills, { + const skillsRaw = (await convexHttp.action(api.search.searchNativeSkills, { query: trimmed, limit: SEARCH_PAGE_SIZE + 1, })) as Array<{ diff --git a/src/routes/skills/-SkillsResults.tsx b/src/routes/skills/-SkillsResults.tsx index 85302bce..53e3d3b3 100644 --- a/src/routes/skills/-SkillsResults.tsx +++ b/src/routes/skills/-SkillsResults.tsx @@ -1,15 +1,24 @@ import { Link } from "@tanstack/react-router"; -import { Plus } from "lucide-react"; +import { Download, ExternalLink, Plus } from "lucide-react"; import type { RefObject } from "react"; +import { MarketplaceIcon } from "../../components/MarketplaceIcon"; import { BrowseResultsSkeleton } from "../../components/skeletons/BrowseResultsSkeleton"; import { SkillCard } from "../../components/SkillCard"; import { SkillListItem } from "../../components/SkillListItem"; import { SkillStatsTripletLine } from "../../components/SkillStats"; +import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { getSkillBadges } from "../../lib/badges"; +import { formatCompactStat } from "../../lib/numberFormat"; import { timeAgo } from "../../lib/timeAgo"; +import { truncateText } from "../../lib/truncateText"; import { useMediaQuery } from "../../lib/useMediaQuery"; -import { buildSkillHref, type SkillListEntry } from "./-types"; +import { + buildSkillHref, + isExternalSkillListEntry, + type SkillListEntry, + type SkillSearchEntry, +} from "./-types"; import type { SkillsView } from "./-useSkillsBrowseModel"; type SkillsResultsProps = { @@ -25,6 +34,84 @@ type SkillsResultsProps = { loadMore: () => void; }; +function ExternalSkillSearchListItem({ result }: { result: SkillSearchEntry }) { + const owner = result.sourceIdentity.owner ?? result.sourceIdentity.host; + return ( + + +
+
+ + + {truncateText(result.displayName, 48)} + + {owner ? @{owner} : null} + + skills.sh +
+ {result.summary ? ( +

{truncateText(result.summary, 80)}

+ ) : null} +
+
+ External source +
+
+ + Observed {timeAgo(result.updatedAt)} + + {typeof result.sourceIdentity.lifetimeInstalls === "number" ? ( + + + ) : null} + + +
+
+ ); +} + +function ExternalSkillSearchCard({ result }: { result: SkillSearchEntry }) { + const owner = result.sourceIdentity.owner ?? result.sourceIdentity.host; + return ( + +
+ +
+
+

+ {result.displayName} +

+ skills.sh +
+ {owner ? ( +

@{owner}

+ ) : null} +
+ +
+ {result.summary ? ( +

+ {result.summary} +

+ ) : null} +
+ ); +} + export function SkillsResults({ isLoadingSkills, sorted, @@ -62,6 +149,9 @@ export function SkillsResults({ ) : effectiveView === "grid" ? (
{sorted.map((entry) => { + if (isExternalSkillListEntry(entry)) { + return ; + } const skill = entry.skill; const clawdis = entry.latestVersion?.parsed?.clawdis; const isPlugin = Boolean(clawdis?.nix?.plugin); @@ -98,6 +188,11 @@ export function SkillsResults({
{sorted.map((entry) => { + if (isExternalSkillListEntry(entry)) { + return ( + + ); + } const skill = entry.skill; const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null; return ( diff --git a/src/routes/skills/-types.ts b/src/routes/skills/-types.ts index e14e4cee..2519497e 100644 --- a/src/routes/skills/-types.ts +++ b/src/routes/skills/-types.ts @@ -1,7 +1,7 @@ import type { Doc } from "../../../convex/_generated/dataModel"; import type { PublicPublisher, PublicSkill } from "../../lib/publicUser"; -export type SkillListEntry = { +export type NativeSkillListEntry = { skill: PublicSkill; latestVersion: { version: string; @@ -24,13 +24,73 @@ export type SkillListEntry = { }; export type SkillSearchEntry = { - skill: PublicSkill; - version: Doc<"skillVersions"> | null; + id: string; + source: "clawhub" | "skills-sh"; + slug: string; + displayName: string; + summary: string | null; score: number; - ownerHandle?: string | null; - owner?: PublicPublisher | null; + canonicalUrl: string; + links: { + canonical: string; + source: string | null; + }; + official: boolean; + featured: boolean; + publisher: { + kind: "user" | "org"; + handle: string | null; + displayName: string | null; + image: string | null; + official: boolean; + } | null; + 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: unknown; + sourceFreshness: "native" | "observed-only"; + }; + metrics: { + rolling60DayInstalls: number | null; + bookmarks: number | null; + updatedAt: number; + }; + native: { + skill: PublicSkill; + version: Doc<"skillVersions"> | null; + owner: PublicPublisher | null; + ownerHandle: string | null; + } | null; + ownerHandle: string | null; + version: string | null; + downloads: number | null; + updatedAt: number; }; +export type ExternalSkillListEntry = { + external: SkillSearchEntry; + searchScore: number; +}; + +export type SkillListEntry = NativeSkillListEntry | ExternalSkillListEntry; + +export function isExternalSkillListEntry(entry: SkillListEntry): entry is ExternalSkillListEntry { + return "external" in entry; +} + export function buildSkillHref(skill: PublicSkill, ownerHandle?: string | null) { const owner = ownerHandle?.trim() || String(skill.ownerPublisherId ?? skill.ownerUserId); return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`; diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index 183506b3..5a86fe9f 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -9,7 +9,7 @@ import { getSkillCategoriesForSkill, } from "../../lib/categories"; import { parseDir, parseSort, toListSort, type SortDir, type SortKey } from "./-params"; -import type { SkillListEntry, SkillSearchEntry } from "./-types"; +import { isExternalSkillListEntry, type SkillListEntry, type SkillSearchEntry } from "./-types"; const pageSize = 25; const maxConsecutiveEmptyPagesPerFetch = 3; @@ -307,60 +307,78 @@ export function useSkillsBrowseModel({ const baseItems = useMemo(() => { if (hasQuery) { - return searchResults.map((entry) => ({ - skill: entry.skill, - latestVersion: entry.version, - ownerHandle: entry.ownerHandle ?? null, - owner: entry.owner ?? null, - searchScore: entry.score, - })); + return searchResults.map( + (entry): SkillListEntry => + entry.native + ? { + skill: entry.native.skill, + latestVersion: entry.native.version, + ownerHandle: entry.native.ownerHandle, + owner: entry.native.owner, + searchScore: entry.score, + } + : { external: entry, searchScore: entry.score }, + ); } return listResults; }, [hasQuery, listResults, searchResults]); const sorted = useMemo(() => { const topicItems = activeTopic - ? baseItems.filter((entry) => getCatalogTopicSlugs(entry.skill.topics).includes(activeTopic)) + ? baseItems.filter( + (entry) => + isExternalSkillListEntry(entry) || + getCatalogTopicSlugs(entry.skill.topics).includes(activeTopic), + ) : baseItems; const categoryItems = activeCategory - ? topicItems.filter((entry) => - getSkillCategoriesForSkill(entry.skill).some( - (category) => category.slug === activeCategory.slug, - ), + ? topicItems.filter( + (entry) => + isExternalSkillListEntry(entry) || + getSkillCategoriesForSkill(entry.skill).some( + (category) => category.slug === activeCategory.slug, + ), ) : topicItems; - if (!hasQuery) { + if (!hasQuery || sort === "relevance") { + // The canonical search action already ordered mixed results. Preserve + // that order exactly for web/API/CLI parity. return categoryItems; } const multiplier = dir === "asc" ? 1 : -1; const results = [...categoryItems]; results.sort((a, b) => { + const aSkill = isExternalSkillListEntry(a) ? a.external : a.skill; + const bSkill = isExternalSkillListEntry(b) ? b.external : b.skill; + const aDownloads = isExternalSkillListEntry(a) ? 0 : a.skill.stats.downloads; + const bDownloads = isExternalSkillListEntry(b) ? 0 : b.skill.stats.downloads; + const aStars = isExternalSkillListEntry(a) ? 0 : a.skill.stats.stars; + const bStars = isExternalSkillListEntry(b) ? 0 : b.skill.stats.stars; const tieBreak = () => { - const updated = (a.skill.updatedAt - b.skill.updatedAt) * multiplier; + const updated = (aSkill.updatedAt - bSkill.updatedAt) * multiplier; if (updated !== 0) return updated; - return a.skill.slug.localeCompare(b.skill.slug); + return aSkill.slug.localeCompare(bSkill.slug); }; switch (sort) { - case "relevance": - return ((a.searchScore ?? 0) - (b.searchScore ?? 0)) * multiplier; case "downloads": - return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier || tieBreak(); + return (aDownloads - bDownloads) * multiplier || tieBreak(); case "stars": - return (a.skill.stats.stars - b.skill.stats.stars) * multiplier || tieBreak(); + return (aStars - bStars) * multiplier || tieBreak(); case "updated": return ( - (a.skill.updatedAt - b.skill.updatedAt) * multiplier || - a.skill.slug.localeCompare(b.skill.slug) + (aSkill.updatedAt - bSkill.updatedAt) * multiplier || + aSkill.slug.localeCompare(bSkill.slug) ); case "name": return ( - (a.skill.displayName.localeCompare(b.skill.displayName) || - a.skill.slug.localeCompare(b.skill.slug)) * multiplier + (aSkill.displayName.localeCompare(bSkill.displayName) || + aSkill.slug.localeCompare(bSkill.slug)) * multiplier ); default: return ( - (a.skill.createdAt - b.skill.createdAt) * multiplier || - a.skill.slug.localeCompare(b.skill.slug) + (("createdAt" in aSkill ? aSkill.createdAt : aSkill.updatedAt) - + ("createdAt" in bSkill ? bSkill.createdAt : bSkill.updatedAt)) * + multiplier || aSkill.slug.localeCompare(bSkill.slug) ); } });