Files
clawhub/convex/lib/skillPublish.ts
T
Patrick Erichsen fd610627e0 feat: ingest and render skill presentation metadata (#3261)
* feat: ingest skill presentation metadata

* feat: render skill icons and clean titles

* feat: add skill presentation backfill

* fix: preserve hosted icons during backfill

* docs: clarify backfill icon ownership

* fix: track skill presentation provenance
2026-07-24 18:36:28 -05:00

1197 lines
38 KiB
TypeScript

import {
decodeUtf8Text,
normalizeCatalogTopics,
normalizeContentType,
normalizeSkillCategories,
resolveSkillCategories,
} from "clawhub-schema";
import { ConvexError } from "convex/values";
import semver from "semver";
import { api, internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import type { ActionCtx, MutationCtx } from "../_generated/server";
import {
isDecodableSkillPresentationRaster,
storeSkillPresentationAsset,
} from "../skillPresentationAssets";
import { getSkillBadgeMap, isSkillHighlighted } from "./badges";
import { generateChangelogForPublish } from "./changelog";
import { generateEmbedding } from "./embeddings";
import { requireGitHubAccountAge } from "./githubAccount";
import type { PublicUser } from "./public";
import {
findOversizedPublishFile,
getPublishFileSizeError,
getPublishTotalSizeError,
MAX_PUBLISH_TOTAL_BYTES,
} from "./publishLimits";
import { isSkillCardPath } from "./skillCards";
import {
MAX_SKILL_PRESENTATION_YAML_BYTES,
OPENAI_SKILL_PRESENTATION_PATH,
parseOpenAiSkillPresentation,
resolveSkillPresentation,
stripPresentationEmoji,
validateSkillPresentationIcon,
} from "./skillPresentation";
import {
computeQualitySignals,
evaluateQuality,
getTrustTier,
type QualityAssessment,
toStructuralFingerprint,
} from "./skillQuality";
import {
buildEmbeddingText,
getFrontmatterMetadata,
getFrontmatterValue,
hashSkillFiles,
isMacJunkPath,
parseClawdisMetadata,
parseFrontmatter,
sanitizePath,
} from "./skills";
import { assertValidSkillSlug, normalizeSkillSlug } from "./skillSlugValidator";
import { generateSkillSummary } from "./skillSummary";
import { normalizeSkillTags } from "./skillTags";
import { runStaticPublishScan } from "./staticPublishScan";
import { getWebhookConfig, type WebhookSkillPayload } from "./webhooks";
const MAX_FILES_FOR_EMBEDDING = 40;
const MAX_ANALYZED_FILE_BYTES = 256 * 1024;
const QUALITY_WINDOW_MS = 24 * 60 * 60 * 1000;
const QUALITY_ACTIVITY_LIMIT = 60;
const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
const SECURITY_SCAN_ENQUEUE_BACKUP_DELAY_MS = 15_000;
const MAX_PUBLISH_SUMMARY_LENGTH = 300;
type FingerprintFile = { path: string; sha256: string };
type SafePublishFile = PublishVersionArgs["files"][number] & { path: string };
type PublishFileBlob = { file: SafePublishFile; blob: Blob };
type DeferredAiEnrichment = {
summary: {
mode: "generate" | "literal";
literal?: string;
currentSummary?: string;
};
changelog: {
source: "auto" | "user";
supplied: string;
};
};
function normalizeStoredSkillCategoryOverride(categories: readonly string[] | undefined) {
if (categories === undefined) return undefined;
try {
return normalizeSkillCategories(categories);
} catch {
return undefined;
}
}
export type PublishResult = {
skillId: Id<"skills">;
versionId: Id<"skillVersions">;
embeddingId?: Id<"skillEmbeddings">;
status?: "pending" | "published";
slug?: string;
version?: string;
publicationStatus?: "pending" | "published";
attemptId?: Id<"publishAttempts">;
createdNewParent?: boolean;
};
type SkillPublishFollowup = {
skipWebhook?: boolean;
ownerHandle?: string;
slug: string;
version: string;
displayName: string;
};
export type SkillPublishResult = PublishResult;
export type PublishVersionArgs = {
slug: string;
displayName: string;
version: string;
changelog: string;
tags?: string[];
categories?: string[];
topics?: string[];
summary?: string;
forkOf?: { slug: string; ownerHandle?: string; version?: string };
source?: {
kind: "github";
url: string;
repo: string;
ref: string;
commit: string;
path: string;
importedAt: number;
};
files: Array<{
path: string;
size: number;
storageId: Id<"_storage">;
sha256: string;
contentType?: string;
}>;
};
export type PublishOptions = {
bypassGitHubAccountAge?: boolean;
bypassNewSkillRateLimit?: boolean;
bypassQualityGate?: boolean;
skipWebhook?: boolean;
ownerHandle?: string;
ownerPublisherId?: Id<"publishers">;
sourceOwnerPublisherId?: Id<"publishers">;
sourceProvenance?: PublishVersionArgs["source"];
// Explicit opt-in to owner migration. The `insertVersion` mutation refuses
// to rewrite a skill's `ownerPublisherId` unless this is `true`, so default
// publishes (including older CLIs that never pass this flag) can never
// accidentally transfer ownership.
migrateOwner?: boolean;
stagePrePublicationChecks?: boolean;
};
type InternalPublishOptions = PublishOptions;
export async function publishVersionForUser(
ctx: ActionCtx,
userId: Id<"users">,
args: PublishVersionArgs,
options: PublishOptions = {},
): Promise<SkillPublishResult> {
return await publishVersionForUserInternal(ctx, userId, args, {
...options,
stagePrePublicationChecks:
options.stagePrePublicationChecks ?? stagedPrePublicationPublishesEnabled(),
});
}
export async function stageSkillPublishAttemptForUser(
ctx: ActionCtx,
userId: Id<"users">,
args: PublishVersionArgs,
options: PublishOptions & { stagePrePublicationChecks?: boolean } = {},
): Promise<SkillPublishResult> {
return await publishVersionForUserInternal(ctx, userId, args, {
...options,
stagePrePublicationChecks: options.stagePrePublicationChecks ?? true,
});
}
function stagedPrePublicationPublishesEnabled() {
return process.env.CLAWHUB_STAGED_PREPUBLICATION_PUBLISHES === "1";
}
async function publishVersionForUserInternal(
ctx: ActionCtx,
userId: Id<"users">,
args: PublishVersionArgs,
options: InternalPublishOptions,
): Promise<SkillPublishResult> {
const version = args.version.trim();
// Normalize first so we can look up the existing skill before deciding
// how strictly to validate. The reserved-word blocklist and length floor
// are only enforced for brand-new skills; owners of grandfathered slugs
// (reserved, <3 chars, or >48 chars) must still be able to publish new
// versions without being blocked by the write-path validator.
const normalizedSlug = normalizeSkillSlug(args.slug);
if (!normalizedSlug) throw new ConvexError("Slug is required.");
let displayName = stripPresentationEmoji(args.displayName.trim());
if (!displayName) throw new ConvexError("Display name required");
if (!semver.valid(version)) {
throw new ConvexError("Version must be valid semver");
}
if (!options.bypassGitHubAccountAge) {
await requireGitHubAccountAge(ctx, userId);
}
const existingSkill = (await ctx.runQuery(internal.skills.getSkillForPublishPreflightInternal, {
userId,
slug: normalizedSlug,
ownerPublisherId: options.ownerPublisherId,
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
migrateOwner: options.migrateOwner,
})) as Doc<"skills"> | null;
if (options.stagePrePublicationChecks && existingSkill && !existingSkill.softDeletedAt) {
const existingVersion = (await ctx.runQuery(
internal.skills.getVersionBySkillAndVersionInternal,
{
skillId: existingSkill._id,
version,
},
)) as Doc<"skillVersions"> | null;
if (existingVersion) {
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
}
if (options.stagePrePublicationChecks) {
const existingAttempt = (await ctx.runQuery(
internal.publishAttempts.findExistingPublishAttemptForArtifactInternal,
{
kind: "skill",
slug: normalizedSlug,
version,
userId,
ownerPublisherId: options.ownerPublisherId,
},
)) as { attemptId: Id<"publishAttempts"> } | null;
if (existingAttempt) {
throw new ConvexError(
`Version ${version} already exists. Increment the version number and try again.`,
);
}
}
const isNewSkill = !existingSkill;
// For new skills, enforce the full write-path rules (length, pattern,
// reserved-word blocklist). For existing skills the slug is already
// persisted and grandfathered — re-validating it would block legitimate
// version publishes on legacy rows.
if (isNewSkill) {
assertValidSkillSlug(normalizedSlug);
}
const slug = normalizedSlug;
const suppliedChangelog = args.changelog.trim();
const changelogSource = suppliedChangelog ? ("user" as const) : ("auto" as const);
const sanitizedFiles = args.files.map((file) => ({
...file,
path: sanitizePath(file.path),
contentType: normalizeContentType(file.contentType),
}));
if (sanitizedFiles.some((file) => !file.path)) {
throw new ConvexError("Invalid file paths");
}
const safeFiles = sanitizedFiles.map((file) => ({
...file,
path: file.path as string,
}));
const publishFilesWithCallerMetadata = safeFiles.filter((file) => !isMacJunkPath(file.path));
if (publishFilesWithCallerMetadata.some((file) => isSkillCardPath(file.path))) {
throw new ConvexError("skill-card.md is generated by ClawHub and cannot be published directly");
}
const publishFiles = await derivePublishFilesFromStorage(ctx, publishFilesWithCallerMetadata);
const readmeFile = publishFiles.find(
(file) => file.path?.toLowerCase() === "skill.md" || file.path?.toLowerCase() === "skills.md",
);
if (!readmeFile) throw new ConvexError("SKILL.md is required");
const readmeText = await fetchText(ctx, readmeFile.storageId);
const frontmatter = parseFrontmatter(readmeText);
const clawdis = parseClawdisMetadata(frontmatter);
const owner = (await ctx.runQuery(internal.users.getByIdInternal, {
userId,
})) as Doc<"users"> | null;
const ownerCreatedAt = owner?.createdAt ?? owner?._creationTime ?? Date.now();
const now = Date.now();
const frontmatterMetadata = getFrontmatterMetadata(frontmatter);
// Check for description in metadata.description (nested) or description (direct frontmatter field)
const metadataDescription =
frontmatterMetadata &&
typeof frontmatterMetadata === "object" &&
!Array.isArray(frontmatterMetadata) &&
typeof (frontmatterMetadata as Record<string, unknown>).description === "string"
? ((frontmatterMetadata as Record<string, unknown>).description as string)
: undefined;
const directDescription = getFrontmatterValue(frontmatter, "description");
// Prioritize the new description from frontmatter over the existing skill summary
// This ensures updates to the description are reflected on subsequent publishes (#301)
const summaryFromFrontmatter = metadataDescription ?? directDescription;
const explicitSummary = args.summary?.trim();
if (explicitSummary && explicitSummary.length > MAX_PUBLISH_SUMMARY_LENGTH) {
throw new ConvexError(`Summary must be ${MAX_PUBLISH_SUMMARY_LENGTH} characters or less`);
}
const openAiFile = publishFiles.find(
(file) => file.path.toLowerCase() === OPENAI_SKILL_PRESENTATION_PATH,
);
const openAiPresentation =
openAiFile && openAiFile.size <= MAX_SKILL_PRESENTATION_YAML_BYTES
? await fetchText(ctx, openAiFile.storageId)
.then(parseOpenAiSkillPresentation)
.catch(() => null)
: null;
const existingLatestVersion = existingSkill?.latestVersionId
? ((await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: existingSkill.latestVersionId,
})) as Doc<"skillVersions"> | null)
: null;
const existingPresentation = existingLatestVersion?.parsed.presentation;
const skillDisplayName = getFrontmatterValue(frontmatter, "name")?.trim();
const defaultDisplayName = resolveSkillPresentation({ slug }).displayName;
const reusesDerivedDisplayName =
existingPresentation?.displayNameSource !== undefined &&
existingPresentation.displayNameSource !== "publisher" &&
existingPresentation.displayName === displayName &&
existingSkill?.displayName === displayName;
const publisherDisplayName =
reusesDerivedDisplayName ||
[skillDisplayName, defaultDisplayName].some(
(candidate) => candidate && stripPresentationEmoji(candidate) === displayName,
)
? undefined
: displayName;
const reusesDerivedSummary =
explicitSummary !== undefined &&
existingPresentation?.summarySource !== undefined &&
existingPresentation.summarySource !== "publisher" &&
existingPresentation.summary === explicitSummary &&
existingSkill?.summary === explicitSummary;
const publisherSummary =
explicitSummary && explicitSummary !== summaryFromFrontmatter && !reusesDerivedSummary
? explicitSummary
: undefined;
const presentation = resolveSkillPresentation({
publisherDisplayName,
publisherSummary,
openAi: openAiPresentation,
skillDisplayName,
skillDescription: summaryFromFrontmatter,
slug,
});
displayName = presentation.displayName;
const shouldDeferAiEnrichment = options.stagePrePublicationChecks === true;
const summary =
publisherSummary ||
(shouldDeferAiEnrichment
? (presentation.summary ?? existingSkill?.summary ?? "")
: await generateSkillSummary({
slug,
displayName,
readmeText,
currentSummary: presentation.summary ?? existingSkill?.summary ?? undefined,
}));
let qualityAssessment: QualityAssessment | null = null;
if (isNewSkill && !options.bypassQualityGate) {
const ownerActivity = (await ctx.runQuery(internal.skills.getOwnerSkillActivityInternal, {
ownerUserId: userId,
limit: QUALITY_ACTIVITY_LIMIT,
})) as Array<{
slug: string;
summary?: string;
createdAt: number;
latestVersionId?: Id<"skillVersions">;
}>;
const trustTier = getTrustTier(now - ownerCreatedAt, ownerActivity.length);
const qualitySignals = computeQualitySignals({
readmeText,
summary,
});
const recentCandidates = ownerActivity.filter(
(entry) =>
entry.slug !== slug && entry.createdAt >= now - QUALITY_WINDOW_MS && entry.latestVersionId,
);
let similarRecentCount = 0;
for (const entry of recentCandidates) {
const recentVersion = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: entry.latestVersionId as Id<"skillVersions">,
})) as Doc<"skillVersions"> | null;
if (!recentVersion) continue;
const candidateReadmeFile = recentVersion.files.find((file) => {
const lower = file.path.toLowerCase();
return lower === "skill.md" || lower === "skills.md";
});
if (!candidateReadmeFile) continue;
const candidateText = await fetchText(ctx, candidateReadmeFile.storageId);
if (toStructuralFingerprint(candidateText) === qualitySignals.structuralFingerprint) {
similarRecentCount += 1;
}
}
qualityAssessment = evaluateQuality({
signals: qualitySignals,
trustTier,
similarRecentCount,
});
if (qualityAssessment.decision === "reject") {
throw new ConvexError(qualityAssessment.reason);
}
}
const metadata = mergeSourceIntoMetadata(frontmatterMetadata, args.source, qualityAssessment);
const fileContents: Array<{ path: string; content: string }> = [
{ path: readmeFile.path, content: readmeText },
];
for (const file of publishFiles) {
if (!file.path || file.storageId === readmeFile.storageId) continue;
const content = await fetchPreviewText(ctx, file.storageId);
if (content === null) continue;
fileContents.push({ path: file.path, content });
}
const otherFiles = fileContents
.filter((file) => !file.path.toLowerCase().endsWith(".md"))
.slice(0, MAX_FILES_FOR_EMBEDDING);
let categories: string[];
let topics: string[];
try {
categories = resolveSkillCategories({
declared: args.categories ?? normalizeStoredSkillCategoryOverride(existingSkill?.categories),
});
topics = normalizeCatalogTopics(args.topics ?? existingSkill?.topics);
} catch (error) {
throw new ConvexError(error instanceof Error ? error.message : "Invalid catalog metadata");
}
const staticScan = await runStaticPublishScan(ctx, {
slug,
displayName,
summary,
frontmatter,
metadata,
files: publishFiles,
});
const embeddingText = buildEmbeddingText({
frontmatter,
readme: readmeText,
otherFiles,
});
const fingerprintPromise = buildPublishSourceFingerprint(
publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
);
const changelogPromise =
changelogSource === "user" || shouldDeferAiEnrichment
? Promise.resolve(suppliedChangelog)
: generateChangelogForPublish(ctx, {
slug,
version,
readmeText,
files: publishFiles.map((file) => ({ path: file.path, sha256: file.sha256 })),
});
const embeddingPromise = shouldDeferAiEnrichment
? Promise.resolve([] as number[])
: generateEmbedding(embeddingText);
const [fingerprint, changelogText, embedding] = await Promise.all([
fingerprintPromise,
changelogPromise,
embeddingPromise.catch((error) => {
throw new ConvexError(formatEmbeddingError(error));
}),
]);
const icon = await hostDirectSkillPresentationIcon(ctx, publishFiles, presentation.iconPaths);
const skillInsertArgs = {
userId,
ownerPublisherId: options.ownerPublisherId,
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
migrateOwner: options.migrateOwner,
slug,
displayName,
version,
changelog: changelogText,
changelogSource,
sourceProvenance: options.sourceProvenance,
tags: normalizeSkillTags(args.tags),
categories,
topics: topics.length ? topics : undefined,
fingerprint,
forkOf: args.forkOf
? {
slug: args.forkOf.slug.trim().toLowerCase(),
ownerHandle: args.forkOf.ownerHandle?.trim().replace(/^@+/, "") || undefined,
version: args.forkOf.version?.trim() || undefined,
}
: undefined,
bypassNewSkillRateLimit: options.bypassNewSkillRateLimit || undefined,
files: publishFiles.map((file) => ({
...file,
path: file.path,
})),
parsed: {
frontmatter,
metadata,
clawdis,
license: PLATFORM_SKILL_LICENSE,
presentation: {
displayName,
displayNameSource: presentation.displayNameSource,
...(summary ? { summary } : {}),
...(summary ? { summarySource: presentation.summarySource ?? ("generated" as const) } : {}),
...(icon ? { icon } : {}),
},
},
summary,
icon,
staticScan,
embedding,
deferredAiEnrichment: shouldDeferAiEnrichment
? ({
summary:
publisherSummary || presentation.summary
? { mode: "literal", literal: publisherSummary ?? presentation.summary }
: {
mode: "generate",
currentSummary: existingSkill?.summary ?? undefined,
},
changelog: {
source: changelogSource,
supplied: suppliedChangelog,
},
} satisfies DeferredAiEnrichment)
: undefined,
qualityAssessment: qualityAssessment
? {
decision: qualityAssessment.decision,
score: qualityAssessment.score,
reason: qualityAssessment.reason,
trustTier: qualityAssessment.trustTier,
similarRecentCount: qualityAssessment.similarRecentCount,
signals: qualityAssessment.signals,
}
: undefined,
};
let ownerHandle = options.ownerHandle;
if (!ownerHandle && options.ownerPublisherId !== undefined) {
const targetPublisher = (await ctx.runQuery(internal.publishers.getByIdInternal, {
publisherId: options.ownerPublisherId,
})) as Doc<"publishers"> | null;
ownerHandle = targetPublisher?.handle;
}
ownerHandle ??= owner?.handle ?? owner?.displayName ?? owner?.name;
const followup = {
skipWebhook: options.skipWebhook || undefined,
ownerHandle,
slug,
version,
displayName,
};
if (!options.stagePrePublicationChecks) {
const publishResult = (await ctx.runMutation(
internal.skills.insertVersion,
skillInsertArgs,
)) as PublishResult;
await scheduleSkillPublishFollowups(ctx, publishResult, followup);
return {
...publishResult,
status: "published",
slug,
version,
publicationStatus: "published",
};
}
const pendingInsertArgs = {
...skillInsertArgs,
publicationStatus: "pending" as const,
};
const pendingResult = (await ctx.runMutation(
internal.skills.insertVersion,
pendingInsertArgs,
)) as PublishResult;
const staged = (await ctx
.runMutation(internal.publishAttempts.createSkillPublishAttemptInternal, {
userId,
ownerPublisherId: options.ownerPublisherId,
sourceOwnerPublisherId: options.sourceOwnerPublisherId,
skillId: pendingResult.skillId,
skillVersionId: pendingResult.versionId,
createdNewParent: pendingResult.createdNewParent,
slug,
displayName,
version,
idempotencyKey: buildSkillPublishAttemptIdempotencyKey({
userId,
ownerPublisherId: options.ownerPublisherId,
slug,
version,
fingerprint,
}),
artifactFingerprint: fingerprint,
files: publishFiles.map((file) => ({
...file,
path: file.path,
})),
scanContext: buildSkillPublishAttemptScanContext(skillInsertArgs),
followup: {
skipWebhook: followup.skipWebhook,
ownerHandle,
},
})
.catch(async (error) => {
await ctx.runMutation(internal.skills.discardPendingPublicationInternal, {
skillId: pendingResult.skillId,
versionId: pendingResult.versionId,
createdNewParent: pendingResult.createdNewParent,
});
throw error;
})) as {
attemptId: Id<"publishAttempts">;
status: string;
result?: PublishResult;
};
if (staged.status === "finalized" && staged.result) {
return {
...staged.result,
status: "published",
slug,
version,
publicationStatus: "published",
};
}
return {
skillId: pendingResult.skillId,
versionId: pendingResult.versionId,
status: "pending",
slug,
version,
publicationStatus: "pending",
attemptId: staged.attemptId,
};
}
export async function finalizeSkillPublishAttempt(
ctx: ActionCtx,
attemptId: Id<"publishAttempts">,
): Promise<PublishResult> {
const claimId = buildFinalizationClaimId();
const claim = (await ctx.runMutation(
internal.publishAttempts.claimSkillPublishAttemptForFinalizationInternal,
{ attemptId, claimId },
)) as
| {
status: "claimed";
attemptId: Id<"publishAttempts">;
createdAt: number;
skillId?: Id<"skills">;
versionId?: Id<"skillVersions">;
skillInsertArgs?: unknown;
followup: SkillPublishFollowup;
}
| {
status: "finalized";
attemptId: Id<"publishAttempts">;
result: PublishResult;
followup: SkillPublishFollowup;
};
if (claim.status === "finalized") {
return claim.result;
}
let publishResult: PublishResult;
try {
if (claim.versionId) {
const rawPublishArgs = await ctx.runQuery(
internal.skills.getPendingVersionPublishArgsInternal,
{
versionId: claim.versionId,
},
);
const skillInsertArgs = await prepareSkillInsertArgsForFinalization(ctx, rawPublishArgs);
publishResult = (await ctx.runMutation(internal.skills.publishPendingVersionInternal, {
versionId: claim.versionId,
publishArgs: skillInsertArgs,
})) as PublishResult;
} else {
const skillInsertArgs = await prepareSkillInsertArgsForFinalization(
ctx,
claim.skillInsertArgs,
);
publishResult = (await ctx.runMutation(
internal.skills.insertVersion,
skillInsertArgs as never,
)) as PublishResult;
}
} catch (error) {
const existingResult = (await ctx.runQuery(
internal.publishAttempts.findSkillPublishAttemptPublicResultInternal,
{ attemptId: claim.attemptId },
)) as PublishResult | null;
if (!existingResult) {
await releaseSkillPublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
throw error;
}
publishResult = existingResult;
}
try {
await scheduleSkillPublishFollowups(ctx, publishResult, claim.followup);
await ctx.runMutation(internal.publishAttempts.recordSkillPublishAttemptFinalizedInternal, {
attemptId: claim.attemptId,
claimId,
result: publishResult,
});
} catch (error) {
await releaseSkillPublishAttemptFinalizationClaim(ctx, claim.attemptId, claimId, error);
throw error;
}
return publishResult;
}
async function prepareSkillInsertArgsForFinalization(
ctx: ActionCtx,
rawInsertArgs: unknown,
): Promise<unknown> {
if (!rawInsertArgs || typeof rawInsertArgs !== "object" || Array.isArray(rawInsertArgs)) {
return rawInsertArgs;
}
const insertArgs = rawInsertArgs as Record<string, unknown>;
const tags = Array.isArray(insertArgs.tags)
? normalizeSkillTags(insertArgs.tags.filter((tag): tag is string => typeof tag === "string"))
: undefined;
const deferred = insertArgs.deferredAiEnrichment as DeferredAiEnrichment | undefined;
if (!deferred) {
return {
...insertArgs,
...(insertArgs.tags !== undefined ? { tags } : {}),
};
}
const { deferredAiEnrichment: _deferredAiEnrichment, ...prepared } = insertArgs;
const files = Array.isArray(prepared.files)
? (prepared.files as Array<{
path?: unknown;
storageId?: unknown;
contentType?: unknown;
sha256?: unknown;
}>)
: [];
const readmeFile = files.find((file) => {
const path = typeof file.path === "string" ? file.path.toLowerCase() : "";
return path === "skill.md" || path === "skills.md";
});
if (!readmeFile?.storageId || typeof readmeFile.storageId !== "string") {
throw new ConvexError("SKILL.md is required");
}
const readmeText = await fetchText(ctx, readmeFile.storageId as Id<"_storage">);
const frontmatter = parseFrontmatter(readmeText);
const otherFiles: Array<{ path: string; content: string }> = [];
for (const file of files) {
if (file === readmeFile || typeof file.path !== "string") continue;
if (!file.storageId || typeof file.storageId !== "string") continue;
const content = await fetchPreviewText(ctx, file.storageId as Id<"_storage">);
if (content === null) continue;
otherFiles.push({ path: file.path, content });
if (otherFiles.length >= MAX_FILES_FOR_EMBEDDING) break;
}
const summary =
deferred.summary.mode === "literal"
? (deferred.summary.literal ?? "")
: await generateSkillSummary({
slug: stringField(prepared, "slug"),
displayName: stringField(prepared, "displayName"),
readmeText,
currentSummary: deferred.summary.currentSummary,
});
const changelog =
deferred.changelog.source === "user"
? deferred.changelog.supplied
: await generateChangelogForPublish(ctx, {
slug: stringField(prepared, "slug"),
version: stringField(prepared, "version"),
readmeText,
files: files
.filter(
(file): file is { path: string; sha256: string } =>
typeof file.path === "string" && typeof file.sha256 === "string",
)
.map((file) => ({ path: file.path, sha256: file.sha256 })),
});
const embeddingText = buildEmbeddingText({
frontmatter,
readme: readmeText,
otherFiles,
});
const embedding = await generateEmbedding(embeddingText).catch((error) => {
throw new ConvexError(formatEmbeddingError(error));
});
return {
...prepared,
...(prepared.tags !== undefined ? { tags } : {}),
summary,
changelog,
embedding,
};
}
function stringField(record: Record<string, unknown>, field: string) {
const value = record[field];
return typeof value === "string" ? value : "";
}
function buildSkillPublishAttemptScanContext(insertArgs: unknown) {
const record =
insertArgs && typeof insertArgs === "object" ? (insertArgs as Record<string, unknown>) : {};
const parsed =
record.parsed && typeof record.parsed === "object"
? (record.parsed as Record<string, unknown>)
: {};
return stripUndefinedForStoredAttempt({
version: {
staticScan: record.staticScan,
parsed: {
metadata: parsed.metadata,
clawdis: parsed.clawdis,
license: parsed.license,
},
qualityAssessment: record.qualityAssessment,
sourceProvenance: record.sourceProvenance,
},
});
}
async function releaseSkillPublishAttemptFinalizationClaim(
ctx: ActionCtx,
attemptId: Id<"publishAttempts">,
claimId: string,
error: unknown,
) {
await ctx.runMutation(
internal.publishAttempts.releaseSkillPublishAttemptFinalizationClaimInternal,
{
attemptId,
claimId,
error: formatPublishAttemptFinalizationError(error),
},
);
}
async function scheduleSkillPublishFollowups(
ctx: ActionCtx,
publishResult: PublishResult,
followup: SkillPublishFollowup,
) {
await ctx.scheduler.runAfter(0, internal.vt.scanWithVirusTotal, {
versionId: publishResult.versionId,
});
await ctx.scheduler.runAfter(0, internal.securityScan.enqueueSkillVersionScanInternal, {
versionId: publishResult.versionId,
source: "publish",
});
await ctx.scheduler.runAfter(2_000, internal.securityScan.enqueueSkillVersionScanInternal, {
versionId: publishResult.versionId,
source: "publish",
preserveActiveJob: true,
preserveExistingJob: true,
});
await ctx.scheduler.runAfter(
SECURITY_SCAN_ENQUEUE_BACKUP_DELAY_MS,
internal.securityScan.enqueueSkillVersionScanInternal,
{
versionId: publishResult.versionId,
source: "publish",
preserveActiveJob: true,
preserveExistingJob: true,
},
);
if (!followup.skipWebhook && getWebhookConfig().url) {
void schedulePublishWebhook(ctx, {
slug: followup.slug,
version: followup.version,
displayName: followup.displayName,
ownerHandle: followup.ownerHandle,
});
}
}
function mergeSourceIntoMetadata(
metadata: unknown,
source: PublishVersionArgs["source"],
qualityAssessment: QualityAssessment | null = null,
) {
const base =
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? { ...(metadata as Record<string, unknown>) }
: {};
if (source) {
base.source = {
kind: source.kind,
url: source.url,
repo: source.repo,
ref: source.ref,
commit: source.commit,
path: source.path,
importedAt: source.importedAt,
};
}
if (qualityAssessment) {
base._clawhubQuality = {
score: qualityAssessment.score,
decision: qualityAssessment.decision,
trustTier: qualityAssessment.trustTier,
similarRecentCount: qualityAssessment.similarRecentCount,
signals: qualityAssessment.signals,
reason: qualityAssessment.reason,
evaluatedAt: Date.now(),
};
}
return Object.keys(base).length ? base : undefined;
}
function buildSkillPublishAttemptIdempotencyKey(args: {
userId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
slug: string;
version: string;
fingerprint: string;
}) {
const ownerScope = args.ownerPublisherId
? `publisher:${args.ownerPublisherId}`
: `user:${args.userId}`;
return ["skill", ownerScope, args.slug, args.version, args.fingerprint].join(":");
}
function stripUndefinedForStoredAttempt(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stripUndefinedForStoredAttempt);
if (!value || typeof value !== "object") return value;
const result: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value)) {
if (nested !== undefined) result[key] = stripUndefinedForStoredAttempt(nested);
}
return result;
}
function buildFinalizationClaimId() {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}:${Math.random().toString(16).slice(2)}`;
}
async function buildPublishSourceFingerprint(files: FingerprintFile[]) {
return await hashSkillFiles(files.filter((file) => !isSkillCardPath(file.path)));
}
export const __test = {
buildPublishSourceFingerprint,
mergeSourceIntoMetadata,
computeQualitySignals,
evaluateQuality,
toStructuralFingerprint,
derivePublishFilesFromStorage,
buildSkillPublishAttemptIdempotencyKey,
hostDirectSkillPresentationIcon,
};
export async function queueHighlightedWebhook(ctx: MutationCtx, skillId: Id<"skills">) {
const skill = await ctx.db.get(skillId);
if (!skill) return;
const owner = await ctx.db.get(skill.ownerUserId);
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const badges = await getSkillBadgeMap(ctx, skillId);
const payload: WebhookSkillPayload = {
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary ?? undefined,
version: latestVersion?.version ?? undefined,
ownerHandle: owner?.handle ?? owner?.name ?? undefined,
highlighted: isSkillHighlighted({ badges }),
tags: Object.keys(skill.tags ?? {}),
};
await ctx.scheduler.runAfter(0, internal.webhooks.sendDiscordWebhook, {
event: "skill.highlighted",
skill: payload,
});
}
export async function fetchText(
ctx: { storage: { get: (id: Id<"_storage">) => Promise<Blob | null> } },
storageId: Id<"_storage">,
) {
const blob = await ctx.storage.get(storageId);
if (!blob) throw new Error("File missing in storage");
const text = decodeUtf8Text(new Uint8Array(await blob.arrayBuffer()));
if (text === null) throw new Error("File is not valid UTF-8 text");
return text;
}
async function hostDirectSkillPresentationIcon(
ctx: Pick<ActionCtx, "runAction" | "runMutation" | "runQuery" | "storage">,
files: SafePublishFile[],
iconPaths: string[] | undefined,
) {
for (const iconPath of iconPaths ?? []) {
const file = files.find((candidate) => candidate.path === iconPath);
if (!file) continue;
const blob = await ctx.storage.get(file.storageId);
if (!blob) {
throw new ConvexError("Skill presentation icon could not be read. Please retry.");
}
const bytes = new Uint8Array(await blob.arrayBuffer());
let validated: ReturnType<typeof validateSkillPresentationIcon>;
try {
validated = validateSkillPresentationIcon({
path: file.path,
bytes,
contentType: file.contentType ?? blob.type,
});
} catch {
continue;
}
if (
validated.contentType !== "image/svg+xml" &&
!(await isDecodableSkillPresentationRaster(ctx, {
bytes,
contentType: validated.contentType,
}))
) {
continue;
}
const sha256 = await sha256Hex(bytes);
if (sha256 !== file.sha256.toLowerCase()) {
throw new ConvexError("Skill presentation icon changed during upload. Please retry.");
}
return await storeSkillPresentationAsset(ctx, {
bytes,
sha256,
contentType: validated.contentType,
});
}
return undefined;
}
async function fetchPreviewText(
ctx: { storage: { get: (id: Id<"_storage">) => Promise<Blob | null> } },
storageId: Id<"_storage">,
) {
const blob = await ctx.storage.get(storageId);
if (!blob) throw new Error("File missing in storage");
if (blob.size > MAX_ANALYZED_FILE_BYTES) return null;
const bytes = new Uint8Array(await blob.arrayBuffer());
return decodeUtf8Text(bytes);
}
async function loadPublishFileBlobs(
ctx: Pick<ActionCtx, "storage">,
files: SafePublishFile[],
): Promise<PublishFileBlob[]> {
const filesWithBlobs: PublishFileBlob[] = [];
for (const file of files) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) throw new ConvexError("File missing in storage");
const storedContentType = blob.type || file.contentType || "application/octet-stream";
const contentType = normalizeContentType(storedContentType);
filesWithBlobs.push({
blob,
file: {
...file,
size: blob.size,
contentType,
},
});
}
return filesWithBlobs;
}
async function derivePublishFilesFromStorage(
ctx: Pick<ActionCtx, "storage">,
files: SafePublishFile[],
) {
const publishFileBlobs = await loadPublishFileBlobs(ctx, files);
const publishFilesWithStorageMetadata = publishFileBlobs.map(({ file }) => file);
const oversizedFile = findOversizedPublishFile(publishFilesWithStorageMetadata);
if (oversizedFile) {
throw new ConvexError(getPublishFileSizeError(oversizedFile.path));
}
const totalBytes = publishFilesWithStorageMetadata.reduce((sum, file) => sum + file.size, 0);
if (totalBytes > MAX_PUBLISH_TOTAL_BYTES) {
throw new ConvexError(getPublishTotalSizeError("skill bundle"));
}
return await hashStoredPublishFiles(publishFileBlobs);
}
async function hashStoredPublishFiles(filesWithBlobs: PublishFileBlob[]) {
const publishFiles: SafePublishFile[] = [];
for (const { file, blob } of filesWithBlobs) {
const bytes = new Uint8Array(await blob.arrayBuffer());
publishFiles.push({
...file,
sha256: await sha256Hex(bytes),
});
}
return publishFiles;
}
async function sha256Hex(bytes: Uint8Array) {
const data = new Uint8Array(bytes);
const digest = await crypto.subtle.digest("SHA-256", data);
return toHex(new Uint8Array(digest));
}
function toHex(bytes: Uint8Array) {
let out = "";
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
return out;
}
function formatEmbeddingError(error: unknown) {
if (error instanceof Error) {
if (error.message.includes("OPENAI_API_KEY")) {
return "OPENAI_API_KEY is not configured.";
}
if (error.message.startsWith("Embedding failed")) {
return error.message;
}
}
return "Embedding failed. Please try again.";
}
function formatPublishAttemptFinalizationError(error: unknown) {
if (error instanceof Error) return error.message.slice(0, 500);
return String(error).slice(0, 500);
}
async function schedulePublishWebhook(
ctx: ActionCtx,
params: { slug: string; version: string; displayName: string; ownerHandle?: string },
) {
const result = (await ctx.runQuery(api.skills.getBySlug, {
slug: params.slug,
ownerHandle: params.ownerHandle,
})) as { skill: Doc<"skills">; owner: PublicUser | null } | null;
if (!result?.skill) return;
const payload: WebhookSkillPayload = {
slug: result.skill.slug,
displayName: result.skill.displayName || params.displayName,
summary: result.skill.summary ?? undefined,
version: params.version,
ownerHandle: result.owner?.handle ?? result.owner?.name ?? undefined,
highlighted: isSkillHighlighted(result.skill),
tags: Object.keys(result.skill.tags ?? {}),
};
await ctx.scheduler.runAfter(0, internal.webhooks.sendDiscordWebhook, {
event: "skill.publish",
skill: payload,
});
}