mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: add canonical mixed skill search (#3264)
* feat: add canonical mixed skill search * test: add permanent Test search proof
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(" "),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 }));
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user