feat: add canonical mixed skill search (#3264)

* feat: add canonical mixed skill search

* test: add permanent Test search proof
This commit is contained in:
Patrick Erichsen
2026-07-25 02:06:41 -05:00
committed by GitHub
parent 5fbd52e137
commit 65ea02f4ca
39 changed files with 3402 additions and 311 deletions
+180 -2
View File
@@ -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
+8
View File
@@ -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;
+24
View File
@@ -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<Record<string, unknown>>
)._handler;
const seedCanonicalSearchFixtureHandler = (
seedCanonicalSearchFixture as unknown as WrappedHandler<Record<string, never>>
)._handler;
const seedGitHubBackedSkillSourceHandler = (
seedGitHubBackedSkillSourceMutation as unknown as WrappedHandler<Record<string, unknown>>
)._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<string, unknown> }> = [];
const deletedStorageIds: string[] = [];
+105
View File
@@ -911,6 +911,111 @@ export const seedTestFixtures: ReturnType<typeof internalAction> = 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),
+25
View File
@@ -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(
+3 -24
View File
@@ -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);
+45
View File
@@ -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());
+5 -51
View File
@@ -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) {
+140
View File
@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import {
classifyCanonicalSkillSearchMatch,
compareCanonicalSkillSearchCandidates,
type CanonicalSkillSearchCandidate,
} from "./canonicalSkillSearch";
function candidate(
id: string,
overrides: Partial<CanonicalSkillSearchCandidate> = {},
): 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 });
});
});
+113
View File
@@ -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)
);
}
+9
View File
@@ -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;
@@ -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<string, unknown> {
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,
};
});
}
+36 -3
View File
@@ -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({
+571 -50
View File
@@ -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<Result = { skill: { slug: string; _id: string } }> = {
};
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<Array<Record<string, unknown>>>;
}
)._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<Array<{ skill: { slug: string }; ownerHandle: string | null }>>;
}
)._handler;
const getExternalSkillSearchCandidatesHandler = (
getExternalSkillSearchCandidates as unknown as {
_handler: (ctx: unknown, args: unknown) => Promise<Array<{ externalId: string }>>;
}
)._handler;
const getRollingSkillSearchUsageHandler = (
getRollingSkillSearchUsage as unknown as {
_handler: (
ctx: unknown,
args: unknown,
) => Promise<Array<{ skillId: string; installs: number; bookmarks: number }>>;
}
)._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<typeof getFunctionName>[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<typeof getFunctionName>[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<typeof getFunctionName>[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<typeof getFunctionName>[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<T>(rows: T[], onPaginate?: () => void) {
return vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
onPaginate?.();
+623 -61
View File
@@ -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<QueryCtx, "db">, ownerInfo: Owner
type SkillSearchEntry = {
embeddingId?: Id<"skillEmbeddings">;
nativeDownloads: number;
skill: NonNullable<ReturnType<typeof toPublicSkill>>;
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<SkillSearchEntry, "nativeDownloads"> & {
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<HydratableSkill, "displayName" | "slug" | "summary" | "categories" | "topics">,
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<typeof action> = 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<PublicSearchResult[]> => {
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<PublicSearchResult[]> {
const query = args.query.trim();
if (!query) return [];
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
@@ -484,34 +483,379 @@ export const searchSkills: ReturnType<typeof action> = 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<typeof action> = 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<T>(values: T[], size: number) {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += size) {
chunks.push(values.slice(index, index + size));
}
return chunks;
}
function parseQualifiedSearchIdentity(query: string) {
const normalized = query.trim().replace(/^@/, "").toLowerCase();
const external = normalized.startsWith("skills-sh/")
? normalized.slice("skills-sh/".length)
: normalized.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<typeof action> = action({
args: skillSearchArgs,
handler: async (ctx, args): Promise<CanonicalSkillSearchResult[]> => {
const query = args.query.trim();
if (!query) return [];
const limit = Math.min(Math.max(Math.trunc(args.limit ?? 10), 1), CANONICAL_RESULT_LIMIT_MAX);
const qualified = parseQualifiedSearchIdentity(query);
const nativeArgs = {
...args,
limit: CANONICAL_NATIVE_CANDIDATE_LIMIT,
};
const [nativeMatches, qualifiedNativeMatches, externalMatches] = await Promise.all([
nativeSkillSearch.handler(ctx, nativeArgs),
qualified.native
? (ctx.runQuery(internal.search.getOwnerQualifiedSkillMatch, {
...qualified.native,
nonSuspiciousOnly: args.nonSuspiciousOnly,
highlightedOnly: args.highlightedOnly,
categorySlug: args.categorySlug,
topic: args.topic,
}) as Promise<SkillSearchEntry[]>)
: Promise.resolve([]),
ctx.runQuery(internal.search.getExternalSkillSearchCandidates, {
query,
highlightedOnly: args.highlightedOnly,
categorySlug: args.categorySlug,
topic: args.topic,
...(qualified.external ? { exactExternalId: qualified.external } : {}),
}) as Promise<Doc<"skillsShMirrorDigests">[]>,
]);
const nativeById = new Map<string, PublicSearchResult>();
for (const entry of [...qualifiedNativeMatches, ...nativeMatches]) {
if (args.excludePendingScan && entry.skill.githubScanStatus === "pending") continue;
nativeById.set(String(entry.skill._id), {
...entry,
semanticScore: "semanticScore" in entry ? Number(entry.semanticScore) : 0,
score: "score" in entry ? Number(entry.score) : 0,
});
}
const nativeCandidates = [...nativeById.values()];
const endDay = toDayKey(Date.now());
const usageRows = (
await Promise.all(
chunkValues(
nativeCandidates.map((entry) => entry.skill._id),
ROLLING_USAGE_QUERY_BATCH_SIZE,
).map(
(skillIds) =>
ctx.runQuery(internal.search.getRollingSkillSearchUsage, {
skillIds,
startDay: endDay - (ROLLING_ADOPTION_DAYS - 1),
endDay,
}) as Promise<RollingSkillUsage[]>,
),
)
).flat();
const usageBySkill = new Map(usageRows.map((usage) => [String(usage.skillId), usage]));
const ranked = [
...nativeCandidates.map((entry) =>
buildNativeCanonicalResult(entry, usageBySkill.get(String(entry.skill._id)), query),
),
...externalMatches.map((digest) => buildExternalCanonicalResult(digest, query)),
]
.filter(
(result): result is CanonicalSkillSearchResult & CanonicalSkillSearchCandidate =>
result !== null,
)
.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<SkillSearchEntry[]> => {
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
const publisher = await getPublisherByHandle(ctx, args.owner);
let skill = publisher
? await ctx.db
.query("skills")
.withIndex("by_owner_publisher_slug", (q) =>
q.eq("ownerPublisherId", publisher._id).eq("slug", args.slug),
)
.unique()
: null;
if (!skill) {
const user = await getActiveUserByHandleOrPersonalPublisher(ctx, args.owner);
if (!user) return [];
skill = await ctx.db
.query("skills")
.withIndex("by_owner_slug", (q) => q.eq("ownerUserId", user._id).eq("slug", args.slug))
.unique();
}
if (!skill || skill.softDeletedAt) return [];
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return [];
if (args.highlightedOnly && !isSkillHighlighted(skill)) return [];
if (!matchesCatalogFilters(skill, categorySlug, topic)) return [];
if (!(await hasResolvablePublicBrowseVersionFromState(ctx, skill, undefined))) return [];
const directOwner =
publisher && skill.ownerPublisherId === publisher._id
? await toPublicPublisherWithOfficial(ctx, publisher)
: null;
const resolved = directOwner
? { ownerHandle: directOwner.handle ?? null, owner: directOwner }
: await makeOwnerInfoGetter(ctx)(skill.ownerUserId, skill.ownerPublisherId);
const publicSkill = toPublicSearchSkill(skill);
if (!resolved.owner || !publicSkill) return [];
return [
{
skill: publicSkill,
version: null,
ownerHandle: resolved.ownerHandle,
owner: resolved.owner,
},
];
},
});
function isPublicExternalSearchDigest(digest: Doc<"skillsShMirrorDigests">) {
return (
digest.active &&
digest.publicVisible &&
digest.installable &&
digest.sourceFreshnessStatus === "observed-only" &&
digest.tombstonedAt === undefined
);
}
const MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX =
CANONICAL_SKILL_SEARCH_BOUNDS.externalCandidateLimitPerIndex;
export const getExternalSkillSearchCandidates = internalQuery({
args: {
query: v.string(),
exactExternalId: v.optional(v.string()),
highlightedOnly: v.optional(v.boolean()),
categorySlug: v.optional(v.string()),
topic: v.optional(v.string()),
},
handler: async (ctx, args): Promise<Doc<"skillsShMirrorDigests">[]> => {
if (args.highlightedOnly) return [];
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
if (categorySlug === null) return [];
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
if (args.topic !== undefined && !topic) return [];
const normalizedQuery = normalizeSkillSearchText(args.query);
if (!normalizedQuery) return [];
const firstToken = getFirstSearchToken(args.query);
const upperBound = prefixUpperBound(normalizedQuery);
const firstTokenUpperBound = firstToken ? prefixUpperBound(firstToken) : null;
const [exact, slug, displayName, slugFirstToken, displayNameFirstToken, fullText] =
await Promise.all([
args.exactExternalId
? ctx.db
.query("skillsShMirrorDigests")
.withIndex("by_external_id", (q) => q.eq("externalId", args.exactExternalId!))
.unique()
: Promise.resolve(null),
ctx.db
.query("skillsShMirrorDigests")
.withIndex("by_active_visible_installable_fresh_slug", (q) =>
q
.eq("active", true)
.eq("publicVisible", true)
.eq("installable", true)
.eq("sourceFreshnessStatus", "observed-only")
.gte("normalizedSlug", normalizedQuery)
.lt("normalizedSlug", upperBound),
)
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX),
ctx.db
.query("skillsShMirrorDigests")
.withIndex("by_active_visible_installable_fresh_display", (q) =>
q
.eq("active", true)
.eq("publicVisible", true)
.eq("installable", true)
.eq("sourceFreshnessStatus", "observed-only")
.gte("normalizedDisplayName", normalizedQuery)
.lt("normalizedDisplayName", upperBound),
)
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX),
firstTokenUpperBound
? ctx.db
.query("skillsShMirrorDigests")
.withIndex("by_active_visible_installable_fresh_slug_token", (q) =>
q
.eq("active", true)
.eq("publicVisible", true)
.eq("installable", true)
.eq("sourceFreshnessStatus", "observed-only")
.gte("normalizedSlugFirstToken", firstToken)
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
)
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX)
: Promise.resolve([]),
firstTokenUpperBound
? ctx.db
.query("skillsShMirrorDigests")
.withIndex("by_active_visible_installable_fresh_display_token", (q) =>
q
.eq("active", true)
.eq("publicVisible", true)
.eq("installable", true)
.eq("sourceFreshnessStatus", "observed-only")
.gte("normalizedDisplayNameFirstToken", firstToken)
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
)
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX)
: Promise.resolve([]),
ctx.db
.query("skillsShMirrorDigests")
.withSearchIndex("search_by_search_text", (q) =>
q
.search("searchText", args.query)
.eq("active", true)
.eq("publicVisible", true)
.eq("installable", true)
.eq("sourceFreshnessStatus", "observed-only"),
)
.take(MAX_EXTERNAL_SEARCH_CANDIDATES_PER_INDEX),
]);
const candidates = [
...(exact ? [exact] : []),
...slug,
...displayName,
...slugFirstToken,
...displayNameFirstToken,
...fullText,
];
const seen = new Set<string>();
return candidates.filter((digest) => {
if (seen.has(digest.externalId)) return false;
seen.add(digest.externalId);
if (!isPublicExternalSearchDigest(digest)) return false;
if (
categorySlug &&
!(digest.inferredCategories ?? []).some((category) => category === categorySlug)
) {
return false;
}
if (topic && !getCatalogTopicSlugs(digest.inferredTopics).includes(topic)) return false;
return true;
});
},
});
export const getRollingSkillSearchUsage = internalQuery({
args: {
skillIds: v.array(v.id("skills")),
startDay: v.number(),
endDay: v.number(),
},
handler: async (ctx, args): Promise<RollingSkillUsage[]> => {
if (args.skillIds.length > ROLLING_USAGE_QUERY_BATCH_SIZE) {
throw new Error(`skillIds exceeds ${ROLLING_USAGE_QUERY_BATCH_SIZE}`);
}
return await Promise.all(
args.skillIds.map(async (skillId) => {
const rows = await ctx.db
.query("skillDailyStats")
.withIndex("by_skill_day", (q) =>
q.eq("skillId", skillId).gte("day", args.startDay).lte("day", args.endDay),
)
.take(ROLLING_ADOPTION_DAYS);
return {
skillId,
installs: rows.reduce((total, row) => total + Math.max(0, row.installs), 0),
bookmarks: rows.reduce((total, row) => total + Math.max(0, row.bookmarks ?? 0), 0),
};
}),
);
},
});
export const directPrefixSkillMatches = internalQuery({
args: {
query: v.string(),
@@ -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,
};
+236
View File
@@ -0,0 +1,236 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanupCanonicalSearchTestFixture,
readCanonicalSearchTestFixture,
seedCanonicalSearchTestFixture,
} from "./searchTestFixtures";
type WrappedHandler<TArgs> = {
_handler: (ctx: unknown, args: TArgs) => Promise<unknown>;
};
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<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
function createDb() {
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
const counters: Record<string, number> = {};
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<string, unknown>) => {
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<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
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");
});
});
+261
View File
@@ -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<QueryCtx, "db">) {
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<QueryCtx, "db">) {
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 };
},
});
+2
View File
@@ -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"],
+7
View File
@@ -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(" "),
+1
View File
@@ -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",
@@ -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", () => {
+29 -4
View File
@@ -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"}`;
@@ -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,
+42
View File
@@ -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(),
});
@@ -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 });
});
});
@@ -0,0 +1,78 @@
export type CanonicalSearchResult = Record<string, unknown> & {
id: string;
source: "clawhub" | "skills-sh";
};
function isRecord(value: unknown): value is Record<string, unknown> {
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),
};
}
+168
View File
@@ -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<TimedResults> {
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<TimedResults> {
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<TimedResults>) {
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<string, unknown> | 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 }));
+9 -6
View File
@@ -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.
+14 -1
View File
@@ -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/<owner>/<repo>/<slug>` and their canonical ClawHub routes use `/skills-sh/<owner>/<repo>/<slug>`.
## Exact-Match Squat Gate
The exact-match tier is authority, and authority must be earned (issue #3054). An exact full-field
+3 -2
View File
@@ -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
@@ -8,11 +8,14 @@ type WorkflowStep = {
if?: string;
name?: string;
run?: string;
uses?: string;
with?: Record<string, string>;
};
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");
});
});
+4 -4
View File
@@ -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,
+122 -67
View File
@@ -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(<SkillsIndex />);
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;
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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<{
+97 -2
View File
@@ -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 (
<a
href={result.canonicalUrl}
className="skill-list-item skill-list-item-skill skill-list-item-with-taxonomy"
target="_blank"
rel="noreferrer"
>
<MarketplaceIcon kind="skill" label={result.displayName} />
<div className="skill-list-item-body">
<div className="skill-list-item-main">
<span className="skill-list-item-identity">
<span className="skill-list-item-name" title={result.displayName}>
{truncateText(result.displayName, 48)}
</span>
{owner ? <span className="skill-list-item-owner">@{owner}</span> : null}
</span>
<Badge variant="compact">skills.sh</Badge>
</div>
{result.summary ? (
<p className="skill-list-item-summary">{truncateText(result.summary, 80)}</p>
) : null}
</div>
<div className="skill-list-item-taxonomy" aria-label="Source">
<span className="skill-list-item-category">External source</span>
</div>
<div className="skill-list-item-meta">
<span className="skill-list-item-meta-item is-updated">
Observed {timeAgo(result.updatedAt)}
</span>
{typeof result.sourceIdentity.lifetimeInstalls === "number" ? (
<span className="skill-list-item-meta-item" title="skills.sh lifetime installs">
<Download size={14} aria-hidden="true" />
{formatCompactStat(result.sourceIdentity.lifetimeInstalls)}
</span>
) : null}
<span className="skill-list-item-meta-item">
<ExternalLink size={14} aria-hidden="true" /> Source
</span>
</div>
</a>
);
}
function ExternalSkillSearchCard({ result }: { result: SkillSearchEntry }) {
const owner = result.sourceIdentity.owner ?? result.sourceIdentity.host;
return (
<a
href={result.canonicalUrl}
className="card flex min-w-0 flex-col gap-3 p-5 transition-colors hover:border-[color:var(--oc-border-strong)]"
target="_blank"
rel="noreferrer"
>
<div className="flex items-start gap-3">
<MarketplaceIcon kind="skill" label={result.displayName} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-[color:var(--oc-text-primary)]">
{result.displayName}
</h3>
<Badge variant="compact">skills.sh</Badge>
</div>
{owner ? (
<p className="mt-1 truncate text-xs text-[color:var(--oc-text-muted)]">@{owner}</p>
) : null}
</div>
<ExternalLink className="shrink-0 text-[color:var(--oc-text-muted)]" size={16} />
</div>
{result.summary ? (
<p className="line-clamp-3 text-sm leading-6 text-[color:var(--oc-text-secondary)]">
{result.summary}
</p>
) : null}
</a>
);
}
export function SkillsResults({
isLoadingSkills,
sorted,
@@ -62,6 +149,9 @@ export function SkillsResults({
) : effectiveView === "grid" ? (
<div className="grid browse-results-grid">
{sorted.map((entry) => {
if (isExternalSkillListEntry(entry)) {
return <ExternalSkillSearchCard key={entry.external.id} result={entry.external} />;
}
const skill = entry.skill;
const clawdis = entry.latestVersion?.parsed?.clawdis;
const isPlugin = Boolean(clawdis?.nix?.plugin);
@@ -98,6 +188,11 @@ export function SkillsResults({
</div>
<div className="results-list">
{sorted.map((entry) => {
if (isExternalSkillListEntry(entry)) {
return (
<ExternalSkillSearchListItem key={entry.external.id} result={entry.external} />
);
}
const skill = entry.skill;
const ownerHandle = entry.owner?.handle ?? entry.ownerHandle ?? null;
return (
+65 -5
View File
@@ -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)}`;
+44 -26
View File
@@ -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)
);
}
});