mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e6b985d9 | ||
|
|
7fcbcd345a | ||
|
|
ddddb431c2 | ||
|
|
78c27579a3 | ||
|
|
9aebc35d86 | ||
|
|
fc63f47ffa | ||
|
|
0b83ea6ff3 | ||
|
|
191b5763ec | ||
|
|
5397a8e5e0 | ||
|
|
7949402888 | ||
|
|
2e3920d41a | ||
|
|
dd4fc823f6 | ||
|
|
135b9ea9b0 | ||
|
|
17a106cefe | ||
|
|
496da99392 | ||
|
|
107486adfb | ||
|
|
ef9e7f0e57 | ||
|
|
ecc8ad3833 | ||
|
|
93c2b23b72 | ||
|
|
2e492a5b87 | ||
|
|
91c87d1322 | ||
|
|
91b8f160f8 | ||
|
|
32f3ce45e9 | ||
|
|
19bfe48a67 | ||
|
|
19951fccf7 | ||
|
|
7dcada9122 | ||
|
|
dab307cb6d | ||
|
|
e96eb4781c | ||
|
|
f64b098fcc | ||
|
|
243432e04e |
+13
-2
@@ -1,15 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 0.6.1 - 2026-02-13
|
||||
|
||||
### Added
|
||||
- Security: add LLM-based security evaluation during skill publish.
|
||||
- Parsing: recognize `metadata.openclaw` frontmatter and evaluate all skill files for requirements.
|
||||
|
||||
### Changed
|
||||
- Performance: lazy-load Monaco diff viewer on demand (thanks @alexjcm, #212).
|
||||
- Search: improve recall/ranking with lexical fallback and relevance prioritization.
|
||||
- Moderation UX: collapse OpenClaw analysis by default; update spacing and default reasoning model.
|
||||
|
||||
### Fixed
|
||||
- Upload gate: handle GitHub API rate limits and optional authenticated lookup token (thanks @superlowburn, #246).
|
||||
- HTTP: remove `allowH2` from Undici agent to prevent `fetch failed` on Node.js 22+ (#245).
|
||||
- Tests: add root `undici` dev dependency for Node E2E imports (thanks @tanujbhaud, #255).
|
||||
- VirusTotal: fix scan sync race conditions and retry behavior in scan/backfill paths.
|
||||
- Metadata: tolerate trailing commas in JSON metadata.
|
||||
- Auth: allow soft-deleted users to re-authenticate on fresh login, while keeping banned users blocked (thanks @tanujbhaud, #177).
|
||||
- Web: prevent horizontal overflow from long code blocks in skill pages (thanks @bewithgaurav, #183).
|
||||
|
||||
## 0.6.0 - 2026-02-10
|
||||
|
||||
### Added
|
||||
- CLI/API: add `set-role` to change user roles (admin only).
|
||||
- Security: quarantine skill publishes with VirusTotal scans + UI (thanks @aleph8, #130).
|
||||
|
||||
@@ -57,13 +57,14 @@
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.4",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "^7.19.2",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
},
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"name": "clawhub",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.1",
|
||||
"bin": {
|
||||
"clawhub": "bin/clawdhub.js",
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
@@ -1298,7 +1299,7 @@
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
|
||||
"undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
|
||||
"undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -1402,8 +1403,12 @@
|
||||
|
||||
"cheerio/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"cheerio/undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
|
||||
|
||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"clawhub/undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
|
||||
|
||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
@@ -1412,7 +1417,7 @@
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"jsdom/undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
|
||||
"nitro/undici": ["undici@7.19.2", "", {}, "sha512-4VQSpGEGsWzk0VYxyB/wVX/Q7qf9t5znLRgs0dzszr9w9Fej/8RVNQ+S20vdXSAyra/bJ7ZQfGv6ZMj7UEbzSg=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
|
||||
+35
-3
@@ -29,12 +29,13 @@ function makeCtx({
|
||||
describe('handleSoftDeletedUserReauth', () => {
|
||||
const userId = 'users:1' as Id<'users'>
|
||||
|
||||
it('skips when no existing user', async () => {
|
||||
it('skips when user not found', async () => {
|
||||
const { ctx } = makeCtx({ user: null })
|
||||
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: null })
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: userId })
|
||||
|
||||
expect(ctx.db.get).not.toHaveBeenCalled()
|
||||
expect(ctx.db.get).toHaveBeenCalledWith(userId)
|
||||
expect(ctx.db.query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips active users', async () => {
|
||||
@@ -57,6 +58,27 @@ describe('handleSoftDeletedUserReauth', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('restores soft-deleted users on fresh login (existingUserId is null)', async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: null })
|
||||
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: null })
|
||||
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
|
||||
deletedAt: undefined,
|
||||
updatedAt: expect.any(Number),
|
||||
})
|
||||
})
|
||||
|
||||
it('skips reactivation when existingUserId does not match userId', async () => {
|
||||
const otherUserId = 'users:999' as Id<'users'>
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 } })
|
||||
|
||||
await handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: otherUserId })
|
||||
|
||||
expect(ctx.db.query).not.toHaveBeenCalled()
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks banned users with a custom message', async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: { action: 'user.ban' } })
|
||||
|
||||
@@ -66,4 +88,14 @@ describe('handleSoftDeletedUserReauth', () => {
|
||||
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks banned users on fresh login (existingUserId is null)', async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecord: { action: 'user.ban' } })
|
||||
|
||||
await expect(
|
||||
handleSoftDeletedUserReauth(ctx as never, { userId, existingUserId: null }),
|
||||
).rejects.toThrow(BANNED_REAUTH_MESSAGE)
|
||||
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+7
-3
@@ -4,17 +4,21 @@ import type { GenericMutationCtx } from 'convex/server'
|
||||
import { ConvexError } from 'convex/values'
|
||||
import type { DataModel, Id } from './_generated/dataModel'
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE = 'Your account has been suspended.'
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
'Your account has been banned for uploading malicious skills. If you believe this is a mistake, please contact security@openclaw.ai and we will work with you to restore access.'
|
||||
|
||||
export async function handleSoftDeletedUserReauth(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: { userId: Id<'users'>; existingUserId: Id<'users'> | null },
|
||||
) {
|
||||
if (!args.existingUserId) return
|
||||
|
||||
const user = await ctx.db.get(args.userId)
|
||||
if (!user?.deletedAt) return
|
||||
|
||||
// Verify that the incoming identity matches the soft-deleted user to prevent bypass.
|
||||
if (args.existingUserId && args.existingUserId !== args.userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const userId = args.userId
|
||||
const banRecord = await ctx.db
|
||||
.query('auditLogs')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* @vitest-environment node */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { internal } from '../_generated/api'
|
||||
import { requireGitHubAccountAge } from './githubAccount'
|
||||
@@ -18,6 +18,11 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
describe('requireGitHubAccountAge', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('uses cached githubCreatedAt when fresh', async () => {
|
||||
@@ -86,7 +91,9 @@ describe('requireGitHubAccountAge', () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
expect.objectContaining({ headers: { 'User-Agent': 'clawhub' } }),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'User-Agent': 'clawhub' }),
|
||||
}),
|
||||
)
|
||||
expect(runMutation).toHaveBeenCalledWith(internal.users.updateGithubMetaInternal, {
|
||||
userId: 'users:1',
|
||||
@@ -105,11 +112,80 @@ describe('requireGitHubAccountAge', () => {
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false })
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/GitHub account lookup failed/i)
|
||||
})
|
||||
|
||||
it('throws rate-limit error on 403', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 403 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/rate limit exceeded/i)
|
||||
})
|
||||
|
||||
it('throws rate-limit error on 429', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: 0,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 429 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never),
|
||||
).rejects.toThrow(/rate limit exceeded/i)
|
||||
})
|
||||
|
||||
it('includes Authorization header when GITHUB_TOKEN is set', async () => {
|
||||
vi.useFakeTimers()
|
||||
const now = new Date('2026-02-02T12:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
|
||||
vi.stubEnv('GITHUB_TOKEN', 'ghp_test123')
|
||||
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
githubCreatedAt: undefined,
|
||||
githubFetchedAt: now.getTime() - 2 * ONE_DAY_MS,
|
||||
})
|
||||
const runMutation = vi.fn()
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
created_at: '2020-01-01T00:00:00Z',
|
||||
}),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await requireGitHubAccountAge({ runQuery, runMutation } as never, 'users:1' as never)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://api.github.com/users/steipete',
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'User-Agent': 'clawhub',
|
||||
Authorization: 'Bearer ghp_test123',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,10 +24,21 @@ export async function requireGitHubAccountAge(ctx: ActionCtx, userId: Id<'users'
|
||||
const stale = !createdAt || now - fetchedAt > FETCH_TTL_MS
|
||||
|
||||
if (stale) {
|
||||
const headers: Record<string, string> = { 'User-Agent': 'clawhub' }
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${GITHUB_API}/users/${encodeURIComponent(handle)}`, {
|
||||
headers: { 'User-Agent': 'clawhub' },
|
||||
headers,
|
||||
})
|
||||
if (!response.ok) throw new ConvexError('GitHub account lookup failed')
|
||||
if (!response.ok) {
|
||||
if (response.status === 403 || response.status === 429) {
|
||||
throw new ConvexError('GitHub API rate limit exceeded — please try again in a few minutes')
|
||||
}
|
||||
throw new ConvexError('GitHub account lookup failed')
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GitHubUser
|
||||
const parsed = payload.created_at ? Date.parse(payload.created_at) : Number.NaN
|
||||
|
||||
@@ -33,6 +33,8 @@ describe('searchText', () => {
|
||||
expect(matchesExactTokens(['pad'], ['Padel', '/padel', 'Tennis-like sport'])).toBe(true)
|
||||
// "xyz" should not match anything
|
||||
expect(matchesExactTokens(['xyz'], ['GoHome', '/gohome', 'Navigate home'])).toBe(false)
|
||||
// "notion" should not match "annotations" (substring only)
|
||||
expect(matchesExactTokens(['notion'], ['Annotations helper', '/annotations'])).toBe(false)
|
||||
})
|
||||
|
||||
it('matchesExactTokens ignores empty inputs', () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function matchesExactTokens(
|
||||
if (textTokens.length === 0) return false
|
||||
// Require at least one token to prefix-match, allowing vector similarity to determine relevance
|
||||
return queryTokens.some((queryToken) =>
|
||||
textTokens.some((textToken) => textToken.includes(queryToken)),
|
||||
textTokens.some((textToken) => textToken.startsWith(queryToken)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,30 @@ export function getLlmEvalModel(): string {
|
||||
}
|
||||
export const LLM_EVAL_MAX_OUTPUT_TOKENS = 16000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatScalar(value: unknown): string {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
||||
return String(value)
|
||||
}
|
||||
// Avoid throwing on circular structures; fall back to a safe representation.
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value)
|
||||
}
|
||||
}
|
||||
|
||||
function formatWithDefault(value: unknown, defaultLabel: string): string {
|
||||
if (value === undefined || value === null) return defaultLabel
|
||||
return formatScalar(value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -231,17 +255,31 @@ export function assembleEvalUserMessage(ctx: SkillEvalContext): string {
|
||||
const fm = ctx.parsed.frontmatter ?? {}
|
||||
const rawClawdis = (ctx.parsed.clawdis ?? {}) as Record<string, unknown>
|
||||
const meta = (ctx.parsed.metadata ?? {}) as Record<string, unknown>
|
||||
const openclawFallback = (meta.openclaw && typeof meta.openclaw === 'object' && !Array.isArray(meta.openclaw))
|
||||
? (meta.openclaw as Record<string, unknown>)
|
||||
: {}
|
||||
const openclawFallback =
|
||||
meta.openclaw && typeof meta.openclaw === 'object' && !Array.isArray(meta.openclaw)
|
||||
? (meta.openclaw as Record<string, unknown>)
|
||||
: {}
|
||||
const clawdis = Object.keys(rawClawdis).length > 0 ? rawClawdis : openclawFallback
|
||||
const requires = ((clawdis.requires ?? openclawFallback.requires ?? {}) as Record<string, unknown>)
|
||||
const requires = (clawdis.requires ?? openclawFallback.requires ?? {}) as Record<string, unknown>
|
||||
const install = (clawdis.install ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
const codeExtensions = new Set([
|
||||
'.js', '.ts', '.mjs', '.cjs', '.jsx', '.tsx',
|
||||
'.py', '.rb', '.sh', '.bash', '.zsh',
|
||||
'.go', '.rs', '.c', '.cpp', '.java',
|
||||
'.js',
|
||||
'.ts',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.jsx',
|
||||
'.tsx',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.sh',
|
||||
'.bash',
|
||||
'.zsh',
|
||||
'.go',
|
||||
'.rs',
|
||||
'.c',
|
||||
'.cpp',
|
||||
'.java',
|
||||
])
|
||||
const codeFiles = ctx.files.filter((f) => {
|
||||
const ext = f.path.slice(f.path.lastIndexOf('.')).toLowerCase()
|
||||
@@ -250,7 +288,7 @@ export function assembleEvalUserMessage(ctx: SkillEvalContext): string {
|
||||
|
||||
const skillMd =
|
||||
ctx.skillMdContent.length > MAX_SKILL_MD_CHARS
|
||||
? ctx.skillMdContent.slice(0, MAX_SKILL_MD_CHARS) + '\n…[truncated]'
|
||||
? `${ctx.skillMdContent.slice(0, MAX_SKILL_MD_CHARS)}\n…[truncated]`
|
||||
: ctx.skillMdContent
|
||||
|
||||
const sections: string[] = []
|
||||
@@ -274,12 +312,14 @@ export function assembleEvalUserMessage(ctx: SkillEvalContext): string {
|
||||
const userInvocable = fm['user-invocable'] ?? clawdis.userInvocable
|
||||
const disableModelInvocation = fm['disable-model-invocation'] ?? clawdis.disableModelInvocation
|
||||
const os = clawdis.os
|
||||
|
||||
sections.push(`**Flags:**
|
||||
- always: ${always ?? 'false (default)'}
|
||||
- user-invocable: ${userInvocable ?? 'true (default)'}
|
||||
- disable-model-invocation: ${disableModelInvocation ?? 'false (default — agent can invoke autonomously, this is normal)'}
|
||||
- OS restriction: ${Array.isArray(os) ? os.join(', ') : os ?? 'none'}`)
|
||||
- always: ${formatWithDefault(always, 'false (default)')}
|
||||
- user-invocable: ${formatWithDefault(userInvocable, 'true (default)')}
|
||||
- disable-model-invocation: ${formatWithDefault(
|
||||
disableModelInvocation,
|
||||
'false (default — agent can invoke autonomously, this is normal)',
|
||||
)}
|
||||
- OS restriction: ${Array.isArray(os) ? os.join(', ') : formatWithDefault(os, 'none')}`)
|
||||
|
||||
// Requirements
|
||||
const bins = (requires.bins as string[] | undefined) ?? []
|
||||
@@ -299,13 +339,13 @@ export function assembleEvalUserMessage(ctx: SkillEvalContext): string {
|
||||
if (install.length > 0) {
|
||||
const specLines = install.map((spec, i) => {
|
||||
const kind = spec.kind ?? 'unknown'
|
||||
const parts = [`- **[${i}] ${kind}**`]
|
||||
if (spec.formula) parts.push(`formula: ${spec.formula}`)
|
||||
if (spec.package) parts.push(`package: ${spec.package}`)
|
||||
if (spec.module) parts.push(`module: ${spec.module}`)
|
||||
if (spec.url) parts.push(`url: ${spec.url}`)
|
||||
if (spec.archive) parts.push(`archive: ${spec.archive}`)
|
||||
if (spec.extract !== undefined) parts.push(`extract: ${spec.extract}`)
|
||||
const parts = [`- **[${i}] ${formatScalar(kind)}**`]
|
||||
if (spec.formula) parts.push(`formula: ${formatScalar(spec.formula)}`)
|
||||
if (spec.package) parts.push(`package: ${formatScalar(spec.package)}`)
|
||||
if (spec.module) parts.push(`module: ${formatScalar(spec.module)}`)
|
||||
if (spec.url) parts.push(`url: ${formatScalar(spec.url)}`)
|
||||
if (spec.archive) parts.push(`archive: ${formatScalar(spec.archive)}`)
|
||||
if (spec.extract !== undefined) parts.push(`extract: ${formatScalar(spec.extract)}`)
|
||||
if (spec.bins) parts.push(`creates binaries: ${(spec.bins as string[]).join(', ')}`)
|
||||
return parts.join(' | ')
|
||||
})
|
||||
@@ -350,16 +390,21 @@ export function assembleEvalUserMessage(ctx: SkillEvalContext): string {
|
||||
const fileBlocks: string[] = []
|
||||
for (const f of ctx.fileContents) {
|
||||
if (totalChars >= MAX_TOTAL_CHARS) {
|
||||
fileBlocks.push(`\n…[remaining files truncated, ${ctx.fileContents.length - fileBlocks.length} file(s) omitted]`)
|
||||
fileBlocks.push(
|
||||
`\n…[remaining files truncated, ${ctx.fileContents.length - fileBlocks.length} file(s) omitted]`,
|
||||
)
|
||||
break
|
||||
}
|
||||
const content = f.content.length > MAX_FILE_CHARS
|
||||
? f.content.slice(0, MAX_FILE_CHARS) + '\n…[truncated]'
|
||||
: f.content
|
||||
const content =
|
||||
f.content.length > MAX_FILE_CHARS
|
||||
? `${f.content.slice(0, MAX_FILE_CHARS)}\n…[truncated]`
|
||||
: f.content
|
||||
fileBlocks.push(`#### ${f.path}\n\`\`\`\n${content}\n\`\`\``)
|
||||
totalChars += content.length
|
||||
}
|
||||
sections.push(`### File contents\nFull source of all included files. Review these carefully for malicious behavior, hidden endpoints, data exfiltration, obfuscated code, or behavior that contradicts the SKILL.md.\n\n${fileBlocks.join('\n\n')}`)
|
||||
sections.push(
|
||||
`### File contents\nFull source of all included files. Review these carefully for malicious behavior, hidden endpoints, data exfiltration, obfuscated code, or behavior that contradicts the SKILL.md.\n\n${fileBlocks.join('\n\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Reminder to respond in JSON (required by OpenAI json_object mode)
|
||||
@@ -435,7 +480,7 @@ export function parseLlmEvalResponse(raw: string): LlmEvalResponse | null {
|
||||
const ruleId = entry.ruleId ?? 'unknown'
|
||||
const expected = entry.expected_for_purpose ? 'expected' : 'unexpected'
|
||||
const note = entry.note ?? ''
|
||||
return `[${ruleId}] ${expected}: ${note}`
|
||||
return `[${formatScalar(ruleId)}] ${expected}: ${formatScalar(note)}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
+21
-15
@@ -2,15 +2,15 @@ import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import type { Doc, Id } from './_generated/dataModel'
|
||||
import { internalAction } from './_generated/server'
|
||||
import type { SkillEvalContext } from './lib/securityPrompt'
|
||||
import {
|
||||
LLM_EVAL_MAX_OUTPUT_TOKENS,
|
||||
SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
assembleEvalUserMessage,
|
||||
detectInjectionPatterns,
|
||||
getLlmEvalModel,
|
||||
LLM_EVAL_MAX_OUTPUT_TOKENS,
|
||||
parseLlmEvalResponse,
|
||||
SECURITY_EVALUATOR_SYSTEM_PROMPT,
|
||||
} from './lib/securityPrompt'
|
||||
import type { SkillEvalContext } from './lib/securityPrompt'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -191,8 +191,10 @@ export const evaluateWithLlm = internalAction({
|
||||
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = Math.pow(2, attempt) * 2000 + Math.random() * 1000
|
||||
console.log(`[llmEval] Rate limited (${response.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_RETRIES})`)
|
||||
const delay = 2 ** attempt * 2000 + Math.random() * 1000
|
||||
console.log(
|
||||
`[llmEval] Rate limited (${response.status}), retrying in ${Math.round(delay)}ms (attempt ${attempt + 1}/${MAX_RETRIES})`,
|
||||
)
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
continue
|
||||
}
|
||||
@@ -249,12 +251,18 @@ export const evaluateWithLlm = internalAction({
|
||||
`[llmEval] Evaluated ${skill.slug}@${version.version}: ${result.verdict} (${result.confidence} confidence)`,
|
||||
)
|
||||
|
||||
// 10. Update moderation flags if version has a sha256hash
|
||||
if (version.sha256hash) {
|
||||
// 10. Update moderation flags — re-read version to get the sha256hash
|
||||
// that VT may have stored while we were evaluating (both run concurrently).
|
||||
const freshVersion = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: args.versionId,
|
||||
})) as Doc<'skillVersions'> | null
|
||||
|
||||
const sha256hash = freshVersion?.sha256hash ?? version.sha256hash
|
||||
if (sha256hash) {
|
||||
const status = verdictToStatus(result.verdict)
|
||||
if (status === 'malicious' || status === 'suspicious' || status === 'clean') {
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash: version.sha256hash,
|
||||
sha256hash,
|
||||
scanner: 'llm',
|
||||
status,
|
||||
})
|
||||
@@ -287,9 +295,7 @@ export const evaluateBySlug = internalAction({
|
||||
return { error: 'No published version' }
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[llmEval:bySlug] Evaluating ${args.slug} (versionId: ${skill.latestVersionId})`,
|
||||
)
|
||||
console.log(`[llmEval:bySlug] Evaluating ${args.slug} (versionId: ${skill.latestVersionId})`)
|
||||
|
||||
await ctx.scheduler.runAfter(0, internal.llmEval.evaluateWithLlm, {
|
||||
versionId: skill.latestVersionId,
|
||||
@@ -329,10 +335,10 @@ export const backfillLlmEval = internalAction({
|
||||
let accScheduled = args.accScheduled ?? 0
|
||||
let accSkipped = args.accSkipped ?? 0
|
||||
|
||||
const batch = await ctx.runQuery(
|
||||
internal.skills.getActiveSkillBatchForLlmBackfillInternal,
|
||||
{ cursor, batchSize },
|
||||
)
|
||||
const batch = await ctx.runQuery(internal.skills.getActiveSkillBatchForLlmBackfillInternal, {
|
||||
cursor,
|
||||
batchSize,
|
||||
})
|
||||
|
||||
if (batch.skills.length === 0 && batch.done) {
|
||||
console.log('[llmEval:backfill] No more skills to evaluate')
|
||||
|
||||
+295
-2
@@ -1,12 +1,305 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { __test } from './search'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { tokenize } from './lib/searchText'
|
||||
import { __test, lexicalFallbackSkills, searchSkills } from './search'
|
||||
|
||||
const { generateEmbeddingMock, getSkillBadgeMapsMock } = vi.hoisted(() => ({
|
||||
generateEmbeddingMock: vi.fn(),
|
||||
getSkillBadgeMapsMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./lib/embeddings', () => ({
|
||||
generateEmbedding: generateEmbeddingMock,
|
||||
}))
|
||||
|
||||
vi.mock('./lib/badges', () => ({
|
||||
getSkillBadgeMaps: getSkillBadgeMapsMock,
|
||||
isSkillHighlighted: (skill: { badges?: Record<string, unknown> }) =>
|
||||
Boolean(skill.badges?.highlighted),
|
||||
}))
|
||||
|
||||
type WrappedHandler = {
|
||||
_handler: (ctx: unknown, args: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
const searchSkillsHandler = (searchSkills as unknown as WrappedHandler)._handler
|
||||
const lexicalFallbackSkillsHandler = (lexicalFallbackSkills as unknown as WrappedHandler)._handler
|
||||
|
||||
describe('search helpers', () => {
|
||||
it('returns fallback results when vector candidates are empty', async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2])
|
||||
const fallback = [
|
||||
{
|
||||
skill: makePublicSkill({ id: 'skills:orf', slug: 'orf', displayName: 'ORF' }),
|
||||
version: null,
|
||||
ownerHandle: 'steipete',
|
||||
},
|
||||
]
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(fallback)
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: 'orf', limit: 10 },
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].skill.slug).toBe('orf')
|
||||
expect(runQuery).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ query: 'orf', queryTokens: ['orf'] }),
|
||||
)
|
||||
})
|
||||
|
||||
it('applies highlightedOnly filtering in lexical fallback', async () => {
|
||||
const highlighted = makeSkillDoc({
|
||||
id: 'skills:hl',
|
||||
slug: 'orf-highlighted',
|
||||
displayName: 'ORF Highlighted',
|
||||
})
|
||||
const plain = makeSkillDoc({ id: 'skills:plain', slug: 'orf-plain', displayName: 'ORF Plain' })
|
||||
getSkillBadgeMapsMock.mockResolvedValueOnce(
|
||||
new Map([
|
||||
['skills:hl', { highlighted: { byUserId: 'users:mod', at: 1 } }],
|
||||
['skills:plain', {}],
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(
|
||||
makeLexicalCtx({
|
||||
exactSlugSkill: null,
|
||||
recentSkills: [highlighted, plain],
|
||||
}),
|
||||
{ query: 'orf', queryTokens: ['orf'], highlightedOnly: true, limit: 10 },
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].skill.slug).toBe('orf-highlighted')
|
||||
})
|
||||
|
||||
it('includes exact slug match from by_slug even when recent scan is empty', async () => {
|
||||
const exactSlugSkill = makeSkillDoc({ id: 'skills:orf', slug: 'orf', displayName: 'ORF' })
|
||||
getSkillBadgeMapsMock.mockResolvedValueOnce(new Map([['skills:orf', {}]]))
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkill,
|
||||
recentSkills: [],
|
||||
})
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(ctx, {
|
||||
query: 'orf',
|
||||
queryTokens: ['orf'],
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].skill.slug).toBe('orf')
|
||||
expect(ctx.db.query).toHaveBeenCalledWith('skills')
|
||||
})
|
||||
|
||||
it('dedupes overlap and enforces rank + limit across vector and fallback', async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2])
|
||||
const vectorEntries = [
|
||||
{
|
||||
embeddingId: 'skillEmbeddings:a',
|
||||
skill: makePublicSkill({
|
||||
id: 'skills:a',
|
||||
slug: 'foo-a',
|
||||
displayName: 'Foo Alpha',
|
||||
downloads: 10,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'one',
|
||||
},
|
||||
{
|
||||
embeddingId: 'skillEmbeddings:b',
|
||||
skill: makePublicSkill({
|
||||
id: 'skills:b',
|
||||
slug: 'foo-b',
|
||||
displayName: 'Foo Beta',
|
||||
downloads: 2,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'two',
|
||||
},
|
||||
]
|
||||
const fallbackEntries = [
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
id: 'skills:a',
|
||||
slug: 'foo-a',
|
||||
displayName: 'Foo Alpha',
|
||||
downloads: 10,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'one',
|
||||
},
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
id: 'skills:c',
|
||||
slug: 'foo-c',
|
||||
displayName: 'Foo Classic',
|
||||
downloads: 1,
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: 'three',
|
||||
},
|
||||
]
|
||||
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(vectorEntries)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(fallbackEntries)
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([
|
||||
{ _id: 'skillEmbeddings:a', _score: 0.4 },
|
||||
{ _id: 'skillEmbeddings:b', _score: 0.9 },
|
||||
]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: 'foo', limit: 2 },
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].skill.slug).toBe('foo-b')
|
||||
expect(new Set(result.map((entry: { skill: { _id: string } }) => entry.skill._id)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('advances candidate limit until max', () => {
|
||||
expect(__test.getNextCandidateLimit(50, 1000)).toBe(100)
|
||||
expect(__test.getNextCandidateLimit(800, 1000)).toBe(1000)
|
||||
expect(__test.getNextCandidateLimit(1000, 1000)).toBeNull()
|
||||
})
|
||||
|
||||
it('boosts exact slug/name matches over loose matches', () => {
|
||||
const queryTokens = tokenize('notion')
|
||||
const exactScore = __test.scoreSkillResult(queryTokens, 0.4, 'Notion Sync', 'notion-sync', 5)
|
||||
const looseScore = __test.scoreSkillResult(queryTokens, 0.6, 'Notes Sync', 'notes-sync', 500)
|
||||
expect(exactScore).toBeGreaterThan(looseScore)
|
||||
})
|
||||
|
||||
it('adds a popularity prior for equally relevant matches', () => {
|
||||
const queryTokens = tokenize('notion')
|
||||
const lowDownloads = __test.scoreSkillResult(
|
||||
queryTokens,
|
||||
0.5,
|
||||
'Notion Helper',
|
||||
'notion-helper',
|
||||
0,
|
||||
)
|
||||
const highDownloads = __test.scoreSkillResult(
|
||||
queryTokens,
|
||||
0.5,
|
||||
'Notion Helper',
|
||||
'notion-helper',
|
||||
1000,
|
||||
)
|
||||
expect(highDownloads).toBeGreaterThan(lowDownloads)
|
||||
})
|
||||
|
||||
it('merges fallback matches without duplicate skill ids', () => {
|
||||
const primary = [
|
||||
{
|
||||
embeddingId: 'skillEmbeddings:1',
|
||||
skill: { _id: 'skills:1' },
|
||||
},
|
||||
] as unknown as Parameters<typeof __test.mergeUniqueBySkillId>[0]
|
||||
const fallback = [
|
||||
{
|
||||
skill: { _id: 'skills:1' },
|
||||
},
|
||||
{
|
||||
skill: { _id: 'skills:2' },
|
||||
},
|
||||
] as unknown as Parameters<typeof __test.mergeUniqueBySkillId>[1]
|
||||
|
||||
const merged = __test.mergeUniqueBySkillId(primary, fallback)
|
||||
expect(merged).toHaveLength(2)
|
||||
expect(merged.map((entry) => entry.skill._id)).toEqual(['skills:1', 'skills:2'])
|
||||
})
|
||||
})
|
||||
|
||||
function makePublicSkill(params: {
|
||||
id: string
|
||||
slug: string
|
||||
displayName: string
|
||||
downloads?: number
|
||||
}) {
|
||||
return {
|
||||
_id: params.id,
|
||||
_creationTime: 1,
|
||||
slug: params.slug,
|
||||
displayName: params.displayName,
|
||||
summary: `${params.displayName} summary`,
|
||||
ownerUserId: 'users:owner',
|
||||
canonicalSkillId: undefined,
|
||||
forkOf: undefined,
|
||||
latestVersionId: 'skillVersions:1',
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
downloads: params.downloads ?? 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSkillDoc(params: { id: string; slug: string; displayName: string }) {
|
||||
return {
|
||||
...makePublicSkill(params),
|
||||
_creationTime: 1,
|
||||
moderationStatus: 'active',
|
||||
moderationFlags: [],
|
||||
softDeletedAt: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function makeLexicalCtx(params: {
|
||||
exactSlugSkill: ReturnType<typeof makeSkillDoc> | null
|
||||
recentSkills: Array<ReturnType<typeof makeSkillDoc>>
|
||||
}) {
|
||||
return {
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table !== 'skills') throw new Error(`Unexpected table ${table}`)
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === 'by_slug') {
|
||||
return {
|
||||
unique: vi.fn().mockResolvedValue(params.exactSlugSkill),
|
||||
}
|
||||
}
|
||||
if (index === 'by_active_updated') {
|
||||
return {
|
||||
order: () => ({
|
||||
take: vi.fn().mockResolvedValue(params.recentSkills),
|
||||
}),
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`)
|
||||
},
|
||||
}
|
||||
}),
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id.startsWith('users:')) return { _id: id, handle: 'owner' }
|
||||
if (id.startsWith('skillVersions:')) return { _id: id, version: '1.0.0' }
|
||||
return null
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+193
-14
@@ -7,20 +7,86 @@ import { generateEmbedding } from './lib/embeddings'
|
||||
import { toPublicSkill, toPublicSoul } from './lib/public'
|
||||
import { matchesExactTokens, tokenize } from './lib/searchText'
|
||||
|
||||
type HydratedEntry = {
|
||||
embeddingId: Id<'skillEmbeddings'>
|
||||
type SkillSearchEntry = {
|
||||
embeddingId?: Id<'skillEmbeddings'>
|
||||
skill: NonNullable<ReturnType<typeof toPublicSkill>>
|
||||
version: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
type SearchResult = HydratedEntry & { score: number }
|
||||
type SearchResult = SkillSearchEntry & { score: number }
|
||||
|
||||
const SLUG_EXACT_BOOST = 1.4
|
||||
const SLUG_PREFIX_BOOST = 0.8
|
||||
const NAME_EXACT_BOOST = 1.1
|
||||
const NAME_PREFIX_BOOST = 0.6
|
||||
const POPULARITY_WEIGHT = 0.08
|
||||
const FALLBACK_SCAN_LIMIT = 1200
|
||||
|
||||
function getNextCandidateLimit(current: number, max: number) {
|
||||
const next = Math.min(current * 2, max)
|
||||
return next > current ? next : null
|
||||
}
|
||||
|
||||
function matchesAllTokens(
|
||||
queryTokens: string[],
|
||||
candidateTokens: string[],
|
||||
matcher: (candidate: string, query: string) => boolean,
|
||||
) {
|
||||
if (queryTokens.length === 0 || candidateTokens.length === 0) return false
|
||||
return queryTokens.every((queryToken) =>
|
||||
candidateTokens.some((candidateToken) => matcher(candidateToken, queryToken)),
|
||||
)
|
||||
}
|
||||
|
||||
function getLexicalBoost(queryTokens: string[], displayName: string, slug: string) {
|
||||
const slugTokens = tokenize(slug)
|
||||
const nameTokens = tokenize(displayName)
|
||||
|
||||
let boost = 0
|
||||
if (matchesAllTokens(queryTokens, slugTokens, (candidate, query) => candidate === query)) {
|
||||
boost += SLUG_EXACT_BOOST
|
||||
} else if (
|
||||
matchesAllTokens(queryTokens, slugTokens, (candidate, query) => candidate.startsWith(query))
|
||||
) {
|
||||
boost += SLUG_PREFIX_BOOST
|
||||
}
|
||||
|
||||
if (matchesAllTokens(queryTokens, nameTokens, (candidate, query) => candidate === query)) {
|
||||
boost += NAME_EXACT_BOOST
|
||||
} else if (
|
||||
matchesAllTokens(queryTokens, nameTokens, (candidate, query) => candidate.startsWith(query))
|
||||
) {
|
||||
boost += NAME_PREFIX_BOOST
|
||||
}
|
||||
|
||||
return boost
|
||||
}
|
||||
|
||||
function scoreSkillResult(
|
||||
queryTokens: string[],
|
||||
vectorScore: number,
|
||||
displayName: string,
|
||||
slug: string,
|
||||
downloads: number,
|
||||
) {
|
||||
const lexicalBoost = getLexicalBoost(queryTokens, displayName, slug)
|
||||
const popularityBoost = Math.log1p(Math.max(downloads, 0)) * POPULARITY_WEIGHT
|
||||
return vectorScore + lexicalBoost + popularityBoost
|
||||
}
|
||||
|
||||
function mergeUniqueBySkillId(primary: SkillSearchEntry[], fallback: SkillSearchEntry[]) {
|
||||
if (fallback.length === 0) return primary
|
||||
const out = [...primary]
|
||||
const seen = new Set(primary.map((entry) => entry.skill._id))
|
||||
for (const entry of fallback) {
|
||||
if (seen.has(entry.skill._id)) continue
|
||||
seen.add(entry.skill._id)
|
||||
out.push(entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const searchSkills: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
query: v.string(),
|
||||
@@ -43,9 +109,9 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
// Convex vectorSearch max limit is 256; clamp candidate sizes accordingly.
|
||||
const maxCandidate = Math.min(Math.max(limit * 10, 200), 256)
|
||||
let candidateLimit = Math.min(Math.max(limit * 3, 50), 256)
|
||||
let hydrated: HydratedEntry[] = []
|
||||
let hydrated: SkillSearchEntry[] = []
|
||||
let scoreById = new Map<Id<'skillEmbeddings'>, number>()
|
||||
let exactMatches: HydratedEntry[] = []
|
||||
let exactMatches: SkillSearchEntry[] = []
|
||||
|
||||
while (candidateLimit <= maxCandidate) {
|
||||
const results = await ctx.vectorSearch('skillEmbeddings', 'by_embedding', {
|
||||
@@ -56,7 +122,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
|
||||
hydrated = (await ctx.runQuery(internal.search.hydrateResults, {
|
||||
embeddingIds: results.map((result) => result._id),
|
||||
})) as HydratedEntry[]
|
||||
})) as SkillSearchEntry[]
|
||||
|
||||
scoreById = new Map<Id<'skillEmbeddings'>, number>(
|
||||
results.map((result) => [result._id, result._score]),
|
||||
@@ -95,12 +161,34 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
candidateLimit = nextLimit
|
||||
}
|
||||
|
||||
return exactMatches
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
score: scoreById.get(entry.embeddingId) ?? 0,
|
||||
}))
|
||||
const fallbackMatches =
|
||||
exactMatches.length >= limit
|
||||
? []
|
||||
: ((await ctx.runQuery(internal.search.lexicalFallbackSkills, {
|
||||
query,
|
||||
queryTokens,
|
||||
limit: Math.min(Math.max(limit * 4, 200), FALLBACK_SCAN_LIMIT),
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
})) as SkillSearchEntry[])
|
||||
|
||||
const mergedMatches = mergeUniqueBySkillId(exactMatches, fallbackMatches)
|
||||
|
||||
return mergedMatches
|
||||
.map((entry) => {
|
||||
const vectorScore = entry.embeddingId ? (scoreById.get(entry.embeddingId) ?? 0) : 0
|
||||
return {
|
||||
...entry,
|
||||
score: scoreSkillResult(
|
||||
queryTokens,
|
||||
vectorScore,
|
||||
entry.skill.displayName,
|
||||
entry.skill.slug,
|
||||
entry.skill.stats.downloads,
|
||||
),
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.skill)
|
||||
.sort((a, b) => b.score - a.score || b.skill.stats.downloads - a.skill.stats.downloads)
|
||||
.slice(0, limit)
|
||||
},
|
||||
})
|
||||
@@ -115,7 +203,7 @@ export const getBadgeMapsForSkills = internalQuery({
|
||||
|
||||
export const hydrateResults = internalQuery({
|
||||
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
|
||||
handler: async (ctx, args): Promise<HydratedEntry[]> => {
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
@@ -144,7 +232,92 @@ export const hydrateResults = internalQuery({
|
||||
}),
|
||||
)
|
||||
|
||||
return entries.filter((entry): entry is HydratedEntry => entry !== null)
|
||||
return entries.filter((entry): entry is SkillSearchEntry => entry !== null)
|
||||
},
|
||||
})
|
||||
|
||||
export const lexicalFallbackSkills = internalQuery({
|
||||
args: {
|
||||
query: v.string(),
|
||||
queryTokens: v.array(v.string()),
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT)
|
||||
const seenSkillIds = new Set<Id<'skills'>>()
|
||||
const candidateSkills: Doc<'skills'>[] = []
|
||||
|
||||
const slugQuery = args.query.trim().toLowerCase()
|
||||
if (/^[a-z0-9][a-z0-9-]*$/.test(slugQuery)) {
|
||||
const exactSlugSkill = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_slug', (q) => q.eq('slug', slugQuery))
|
||||
.unique()
|
||||
if (exactSlugSkill && !exactSlugSkill.softDeletedAt) {
|
||||
seenSkillIds.add(exactSlugSkill._id)
|
||||
candidateSkills.push(exactSlugSkill)
|
||||
}
|
||||
}
|
||||
|
||||
const recentSkills = await ctx.db
|
||||
.query('skills')
|
||||
.withIndex('by_active_updated', (q) => q.eq('softDeletedAt', undefined))
|
||||
.order('desc')
|
||||
.take(FALLBACK_SCAN_LIMIT)
|
||||
|
||||
for (const skill of recentSkills) {
|
||||
if (seenSkillIds.has(skill._id)) continue
|
||||
seenSkillIds.add(skill._id)
|
||||
candidateSkills.push(skill)
|
||||
}
|
||||
|
||||
const matched = candidateSkills.filter((skill) =>
|
||||
matchesExactTokens(args.queryTokens, [skill.displayName, skill.slug, skill.summary]),
|
||||
)
|
||||
if (matched.length === 0) return []
|
||||
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = ctx.db
|
||||
.get(ownerUserId)
|
||||
.then((owner) => owner?.handle ?? owner?._id ?? null)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
matched.map(async (skill) => {
|
||||
const [version, ownerHandle] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : Promise.resolve(null),
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
const publicSkill = toPublicSkill(skill)
|
||||
if (!publicSkill) return null
|
||||
return { skill: publicSkill, version, ownerHandle }
|
||||
}),
|
||||
)
|
||||
const validEntries = entries.filter((entry): entry is SkillSearchEntry => entry !== null)
|
||||
if (validEntries.length === 0) return []
|
||||
|
||||
const badgeMap = await getSkillBadgeMaps(
|
||||
ctx,
|
||||
validEntries.map((entry) => entry.skill._id),
|
||||
)
|
||||
const withBadges = validEntries.map((entry) => ({
|
||||
...entry,
|
||||
skill: {
|
||||
...entry.skill,
|
||||
badges: badgeMap.get(entry.skill._id) ?? {},
|
||||
},
|
||||
}))
|
||||
|
||||
const filtered = args.highlightedOnly
|
||||
? withBadges.filter((entry) => isSkillHighlighted(entry.skill))
|
||||
: withBadges
|
||||
return filtered.slice(0, limit)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -251,4 +424,10 @@ export const getSkillBadgeMapsInternal = internalQuery({
|
||||
},
|
||||
})
|
||||
|
||||
export const __test = { getNextCandidateLimit }
|
||||
export const __test = {
|
||||
getNextCandidateLimit,
|
||||
matchesAllTokens,
|
||||
getLexicalBoost,
|
||||
scoreSkillResult,
|
||||
mergeUniqueBySkillId,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Test the aggregateEvents function by importing and testing the module logic
|
||||
// Since aggregateEvents is not exported, we test the behavior indirectly through
|
||||
// the event processing contract
|
||||
|
||||
describe('skill stat events - comment delta handling', () => {
|
||||
it('aggregates comment and uncomment events into net deltas', () => {
|
||||
// Simulate the aggregation logic from processSkillStatEventsAction
|
||||
type EventKind =
|
||||
| 'download'
|
||||
| 'star'
|
||||
| 'unstar'
|
||||
| 'comment'
|
||||
| 'uncomment'
|
||||
| 'install_new'
|
||||
| 'install_reactivate'
|
||||
| 'install_deactivate'
|
||||
| 'install_clear'
|
||||
|
||||
const events: { kind: EventKind; occurredAt: number }[] = [
|
||||
{ kind: 'star', occurredAt: 1000 },
|
||||
{ kind: 'comment', occurredAt: 2000 },
|
||||
{ kind: 'comment', occurredAt: 3000 },
|
||||
{ kind: 'uncomment', occurredAt: 4000 },
|
||||
{ kind: 'download', occurredAt: 5000 },
|
||||
]
|
||||
|
||||
// Replicate the aggregation logic
|
||||
const result = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
comments: 0,
|
||||
installsAllTime: 0,
|
||||
installsCurrent: 0,
|
||||
downloadEvents: [] as number[],
|
||||
installNewEvents: [] as number[],
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.kind) {
|
||||
case 'download':
|
||||
result.downloads += 1
|
||||
result.downloadEvents.push(event.occurredAt)
|
||||
break
|
||||
case 'star':
|
||||
result.stars += 1
|
||||
break
|
||||
case 'unstar':
|
||||
result.stars -= 1
|
||||
break
|
||||
case 'comment':
|
||||
result.comments += 1
|
||||
break
|
||||
case 'uncomment':
|
||||
result.comments -= 1
|
||||
break
|
||||
case 'install_new':
|
||||
result.installsAllTime += 1
|
||||
result.installsCurrent += 1
|
||||
result.installNewEvents.push(event.occurredAt)
|
||||
break
|
||||
case 'install_reactivate':
|
||||
result.installsCurrent += 1
|
||||
break
|
||||
case 'install_deactivate':
|
||||
result.installsCurrent -= 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(result.stars).toBe(1)
|
||||
expect(result.comments).toBe(1) // 2 comments - 1 uncomment
|
||||
expect(result.downloads).toBe(1)
|
||||
expect(result.downloadEvents).toEqual([5000])
|
||||
})
|
||||
|
||||
it('should include comments in delta check (regression test for dropped comments)', () => {
|
||||
// This test verifies the fix: the condition guard in applyAggregatedStatsAndUpdateCursor
|
||||
// must include comments !== 0 so comment-only batches are not skipped
|
||||
const delta = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
comments: 3,
|
||||
installsAllTime: 0,
|
||||
installsCurrent: 0,
|
||||
}
|
||||
|
||||
// The OLD buggy condition (missing comments):
|
||||
const oldCondition =
|
||||
delta.downloads !== 0 ||
|
||||
delta.stars !== 0 ||
|
||||
delta.installsAllTime !== 0 ||
|
||||
delta.installsCurrent !== 0
|
||||
|
||||
// The FIXED condition (includes comments):
|
||||
const fixedCondition =
|
||||
delta.downloads !== 0 ||
|
||||
delta.stars !== 0 ||
|
||||
delta.comments !== 0 ||
|
||||
delta.installsAllTime !== 0 ||
|
||||
delta.installsCurrent !== 0
|
||||
|
||||
// With only comment deltas, the old condition would skip the patch
|
||||
expect(oldCondition).toBe(false)
|
||||
// The fixed condition correctly triggers the patch
|
||||
expect(fixedCondition).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -379,12 +379,14 @@ export const applyAggregatedStatsAndUpdateCursor = internalMutation({
|
||||
if (
|
||||
delta.downloads !== 0 ||
|
||||
delta.stars !== 0 ||
|
||||
delta.comments !== 0 ||
|
||||
delta.installsAllTime !== 0 ||
|
||||
delta.installsCurrent !== 0
|
||||
) {
|
||||
const patch = applySkillStatDeltas(skill, {
|
||||
downloads: delta.downloads,
|
||||
stars: delta.stars,
|
||||
comments: delta.comments,
|
||||
installsAllTime: delta.installsAllTime,
|
||||
installsCurrent: delta.installsCurrent,
|
||||
})
|
||||
|
||||
+121
-8
@@ -1420,9 +1420,11 @@ export const getPendingScanSkillsInternal = internalQuery({
|
||||
const skipRecentMinutes = args.skipRecentMinutes ?? 60
|
||||
const skipThreshold = Date.now() - skipRecentMinutes * 60 * 1000
|
||||
|
||||
// Fetch more than needed so we can randomize selection
|
||||
// Fetch more than needed so we can randomize selection.
|
||||
// Include newly-published skills (hidden/pending.scan), skills stuck at
|
||||
// scanner.vt.pending, AND LLM-evaluated skills that still need VT results.
|
||||
const poolSize = Math.min(limit * 3, 500)
|
||||
const allSkills = await ctx.db
|
||||
const pendingScan = await ctx.db
|
||||
.query('skills')
|
||||
.filter((q) =>
|
||||
q.and(
|
||||
@@ -1431,6 +1433,36 @@ export const getPendingScanSkillsInternal = internalQuery({
|
||||
),
|
||||
)
|
||||
.take(poolSize)
|
||||
const vtPending = await ctx.db
|
||||
.query('skills')
|
||||
.filter((q) =>
|
||||
q.and(
|
||||
q.eq(q.field('moderationStatus'), 'active'),
|
||||
q.eq(q.field('moderationReason'), 'scanner.vt.pending'),
|
||||
),
|
||||
)
|
||||
.take(poolSize)
|
||||
// LLM-evaluated skills whose VT scan hasn't completed yet
|
||||
const llmEvaluated = await ctx.db
|
||||
.query('skills')
|
||||
.filter((q) =>
|
||||
q.or(
|
||||
q.eq(q.field('moderationReason'), 'scanner.llm.clean'),
|
||||
q.eq(q.field('moderationReason'), 'scanner.llm.suspicious'),
|
||||
q.eq(q.field('moderationReason'), 'scanner.llm.malicious'),
|
||||
),
|
||||
)
|
||||
.take(poolSize)
|
||||
|
||||
// Dedup across pools by skill ID
|
||||
const seen = new Set<string>()
|
||||
const allSkills: typeof pendingScan = []
|
||||
for (const skill of [...pendingScan, ...vtPending, ...llmEvaluated]) {
|
||||
if (!seen.has(skill._id)) {
|
||||
seen.add(skill._id)
|
||||
allSkills.push(skill)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out recently checked skills
|
||||
const skills = allSkills.filter(
|
||||
@@ -1453,6 +1485,8 @@ export const getPendingScanSkillsInternal = internalQuery({
|
||||
|
||||
for (const skill of selected) {
|
||||
const version = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
// Skip skills where version already has vtAnalysis or lacks sha256hash
|
||||
if (version?.vtAnalysis || !version?.sha256hash) continue
|
||||
results.push({
|
||||
skillId: skill._id,
|
||||
versionId: version?._id ?? null,
|
||||
@@ -1514,8 +1548,10 @@ export const getActiveSkillsMissingVTCacheInternal = internalQuery({
|
||||
args: { limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 100
|
||||
// Use scanner.vt.pending filter to only get skills waiting for VT
|
||||
const pendingSkills = await ctx.db
|
||||
const poolSize = limit * 2 // Take more to account for some having vtAnalysis
|
||||
|
||||
// Skills waiting for VT + LLM-evaluated skills that still need VT cache
|
||||
const vtPending = await ctx.db
|
||||
.query('skills')
|
||||
.filter((q) =>
|
||||
q.and(
|
||||
@@ -1523,7 +1559,27 @@ export const getActiveSkillsMissingVTCacheInternal = internalQuery({
|
||||
q.eq(q.field('moderationReason'), 'scanner.vt.pending'),
|
||||
),
|
||||
)
|
||||
.take(limit * 2) // Take more to account for some having vtAnalysis
|
||||
.take(poolSize)
|
||||
const llmEvaluated = await ctx.db
|
||||
.query('skills')
|
||||
.filter((q) =>
|
||||
q.or(
|
||||
q.eq(q.field('moderationReason'), 'scanner.llm.clean'),
|
||||
q.eq(q.field('moderationReason'), 'scanner.llm.suspicious'),
|
||||
q.eq(q.field('moderationReason'), 'scanner.llm.malicious'),
|
||||
),
|
||||
)
|
||||
.take(poolSize)
|
||||
|
||||
// Dedup across pools
|
||||
const seen = new Set<string>()
|
||||
const allSkills: typeof vtPending = []
|
||||
for (const skill of [...vtPending, ...llmEvaluated]) {
|
||||
if (!seen.has(skill._id)) {
|
||||
seen.add(skill._id)
|
||||
allSkills.push(skill)
|
||||
}
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
skillId: Id<'skills'>
|
||||
@@ -1532,7 +1588,7 @@ export const getActiveSkillsMissingVTCacheInternal = internalQuery({
|
||||
slug: string
|
||||
}> = []
|
||||
|
||||
for (const skill of pendingSkills) {
|
||||
for (const skill of allSkills) {
|
||||
if (results.length >= limit) break
|
||||
if (!skill.latestVersionId) continue
|
||||
const version = await ctx.db.get(skill.latestVersionId)
|
||||
@@ -2059,8 +2115,7 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
} else if (isClean) {
|
||||
// Clean from this scanner — only clear if no other scanner has flagged
|
||||
const otherScannerFlagged =
|
||||
existingReason !== undefined &&
|
||||
existingReason.startsWith('scanner.') &&
|
||||
existingReason?.startsWith('scanner.') &&
|
||||
!existingReason.startsWith(`scanner.${args.scanner}.`) &&
|
||||
!existingReason.endsWith('.clean') &&
|
||||
!existingReason.endsWith('.pending')
|
||||
@@ -2087,6 +2142,64 @@ export const approveSkillByHashInternal = internalMutation({
|
||||
return { ok: true, skillId: version.skillId, versionId: version._id }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Lighter VT-only escalation: adds moderation flags and hides/bans for malicious,
|
||||
* but never touches moderationReason (preserves the LLM verdict).
|
||||
*/
|
||||
export const escalateByVtInternal = internalMutation({
|
||||
args: {
|
||||
sha256hash: v.string(),
|
||||
status: v.union(v.literal('malicious'), v.literal('suspicious')),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const version = await ctx.db
|
||||
.query('skillVersions')
|
||||
.withIndex('by_sha256hash', (q) => q.eq('sha256hash', args.sha256hash))
|
||||
.unique()
|
||||
|
||||
if (!version) throw new Error('Version not found for hash')
|
||||
|
||||
const skill = await ctx.db.get(version.skillId)
|
||||
if (!skill) return
|
||||
|
||||
const isMalicious = args.status === 'malicious'
|
||||
const existingFlags: string[] = (skill.moderationFlags as string[] | undefined) ?? []
|
||||
const alreadyBlocked = existingFlags.includes('blocked.malware')
|
||||
|
||||
// Determine new flags — stricter verdict always wins
|
||||
let newFlags: string[]
|
||||
if (isMalicious || alreadyBlocked) {
|
||||
newFlags = ['blocked.malware']
|
||||
} else {
|
||||
newFlags = ['flagged.suspicious']
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
moderationFlags: newFlags,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
// Only hide for malicious — suspicious stays visible with a flag
|
||||
if (isMalicious) {
|
||||
patch.moderationStatus = 'hidden'
|
||||
}
|
||||
|
||||
await ctx.db.patch(skill._id, patch)
|
||||
|
||||
// Auto-ban authors of malicious skills
|
||||
if (isMalicious && skill.ownerUserId) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.autobanMalwareAuthorInternal, {
|
||||
ownerUserId: skill.ownerUserId,
|
||||
sha256hash: args.sha256hash,
|
||||
slug: skill.slug,
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, skillId: version.skillId, versionId: version._id }
|
||||
},
|
||||
})
|
||||
|
||||
export const getVersionBySkillAndVersion = query({
|
||||
args: { skillId: v.id('skills'), version: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -200,6 +200,102 @@ function buildSkillStatPatch(skill: Doc<'skills'>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile skill stats by counting actual records in source-of-truth tables.
|
||||
*
|
||||
* This fixes stats that got out of sync due to missed events, cursor issues,
|
||||
* or bugs in the event processing pipeline. It counts:
|
||||
* - stars: actual records in the `stars` table for each skill
|
||||
* - comments: actual records in the `comments` table for each skill
|
||||
*
|
||||
* Downloads and installs are event-sourced only (no separate table to count from),
|
||||
* so they cannot be reconciled this way.
|
||||
*/
|
||||
export const reconcileSkillStarCounts = internalMutation({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200)
|
||||
const now = Date.now()
|
||||
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query('skills')
|
||||
.order('asc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize })
|
||||
|
||||
let patched = 0
|
||||
for (const skill of page) {
|
||||
// Count actual star records for this skill
|
||||
const starRecords = await ctx.db
|
||||
.query('stars')
|
||||
.withIndex('by_skill_user', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
const actualStars = starRecords.length
|
||||
|
||||
// Count actual comment records for this skill
|
||||
const commentRecords = await ctx.db
|
||||
.query('comments')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', skill._id))
|
||||
.collect()
|
||||
const actualComments = commentRecords.filter((c) => !c.softDeletedAt).length
|
||||
|
||||
// Check if stats are out of sync
|
||||
if (skill.stats.stars !== actualStars || skill.stats.comments !== actualComments) {
|
||||
const updatedStats = {
|
||||
...skill.stats,
|
||||
stars: actualStars,
|
||||
comments: actualComments,
|
||||
}
|
||||
await ctx.db.patch(skill._id, {
|
||||
statsStars: actualStars,
|
||||
stats: updatedStats,
|
||||
updatedAt: now,
|
||||
})
|
||||
patched += 1
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scanned: page.length,
|
||||
patched,
|
||||
cursor: isDone ? null : continueCursor,
|
||||
isDone,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const runReconcileSkillStarCountsInternal = internalAction({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = clampInt(args.batchSize ?? 50, 1, 200)
|
||||
const maxBatches = clampInt(args.maxBatches ?? 10, 1, 50)
|
||||
|
||||
let cursor: string | undefined
|
||||
let totalScanned = 0
|
||||
let totalPatched = 0
|
||||
|
||||
for (let i = 0; i < maxBatches; i++) {
|
||||
const result = (await ctx.runMutation(internal.statsMaintenance.reconcileSkillStarCounts, {
|
||||
cursor,
|
||||
batchSize,
|
||||
})) as { scanned: number; patched: number; cursor: string | null; isDone: boolean }
|
||||
|
||||
totalScanned += result.scanned
|
||||
totalPatched += result.patched
|
||||
|
||||
if (result.isDone) break
|
||||
cursor = result.cursor ?? undefined
|
||||
}
|
||||
|
||||
return { scanned: totalScanned, patched: totalPatched }
|
||||
},
|
||||
})
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
+27
-29
@@ -393,21 +393,14 @@ export const scanWithVirusTotal = internalAction({
|
||||
},
|
||||
})
|
||||
|
||||
if (isSafe) {
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
// VT is supplementary — only escalate (never override LLM verdict)
|
||||
if (!isSafe && (status === 'malicious' || status === 'suspicious')) {
|
||||
await ctx.runMutation(internal.skills.escalateByVtInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status: 'clean',
|
||||
moderationStatus: 'active',
|
||||
})
|
||||
} else if (status === 'malicious' || status === 'suspicious') {
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
moderationStatus: 'hidden',
|
||||
})
|
||||
}
|
||||
// Clean VT result: vtAnalysis already written above — don't touch moderation
|
||||
return
|
||||
}
|
||||
|
||||
@@ -448,13 +441,9 @@ export const scanWithVirusTotal = internalAction({
|
||||
`Successfully uploaded version ${args.versionId} to VT. Hash: ${sha256hash}. Analysis ID: ${result.data.id}`,
|
||||
)
|
||||
|
||||
// Mark skill as pending scan so it enters the poll queue
|
||||
// This prevents it from being picked up again by scanUnscannedSkills
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status: 'pending',
|
||||
})
|
||||
// Don't set moderation state to scanner.vt.pending here — the LLM eval
|
||||
// runs concurrently and will set the initial moderation state. VT only
|
||||
// updates moderation when it has an actual verdict (clean/suspicious/malicious).
|
||||
} catch (error) {
|
||||
console.error('Failed to upload to VirusTotal:', error)
|
||||
}
|
||||
@@ -529,12 +518,16 @@ export const pollPendingScans = internalAction({
|
||||
const vtResult = await checkExistingFile(apiKey, sha256hash)
|
||||
if (!vtResult) {
|
||||
console.log(`[vt:pollPendingScans] Hash ${sha256hash} not found in VT yet`)
|
||||
// Check if we've exceeded max attempts
|
||||
// Check if we've exceeded max attempts — write stale vtAnalysis so it
|
||||
// drops out of the poll query without overwriting LLM moderationReason
|
||||
if (checkCount + 1 >= MAX_CHECK_COUNT) {
|
||||
console.warn(
|
||||
`[vt:pollPendingScans] Skill ${skillId} exceeded max checks, marking stale`,
|
||||
)
|
||||
await ctx.runMutation(internal.skills.markScanStaleInternal, { skillId })
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
|
||||
})
|
||||
staled++
|
||||
}
|
||||
continue
|
||||
@@ -550,12 +543,16 @@ export const pollPendingScans = internalAction({
|
||||
`[vt:pollPendingScans] Hash ${sha256hash} has no Code Insight, requesting rescan`,
|
||||
)
|
||||
await requestRescan(apiKey, sha256hash)
|
||||
// Check if we've exceeded max attempts
|
||||
// Check if we've exceeded max attempts — write stale vtAnalysis so it
|
||||
// drops out of the poll query without overwriting LLM moderationReason
|
||||
if (checkCount + 1 >= MAX_CHECK_COUNT) {
|
||||
console.warn(
|
||||
`[vt:pollPendingScans] Skill ${skillId} exceeded max checks, marking stale`,
|
||||
)
|
||||
await ctx.runMutation(internal.skills.markScanStaleInternal, { skillId })
|
||||
await ctx.runMutation(internal.skills.updateVersionScanResultsInternal, {
|
||||
versionId,
|
||||
vtAnalysis: { status: 'stale', checkedAt: Date.now() },
|
||||
})
|
||||
staled++
|
||||
}
|
||||
continue
|
||||
@@ -581,11 +578,13 @@ export const pollPendingScans = internalAction({
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt',
|
||||
status,
|
||||
})
|
||||
// VT is supplementary — only escalate for malicious/suspicious
|
||||
if (status === 'malicious' || status === 'suspicious') {
|
||||
await ctx.runMutation(internal.skills.escalateByVtInternal, {
|
||||
sha256hash,
|
||||
status,
|
||||
})
|
||||
}
|
||||
updated++
|
||||
} catch (error) {
|
||||
console.error(`[vt:pollPendingScans] Error checking hash ${sha256hash}:`, error)
|
||||
@@ -835,9 +834,8 @@ export const rescanActiveSkills = internalAction({
|
||||
if (status === 'malicious' || status === 'suspicious') {
|
||||
console.warn(`[vt:rescan] ${slug}: verdict changed to ${status}!`)
|
||||
accFlaggedSkills.push({ slug, status })
|
||||
await ctx.runMutation(internal.skills.approveSkillByHashInternal, {
|
||||
await ctx.runMutation(internal.skills.escalateByVtInternal, {
|
||||
sha256hash,
|
||||
scanner: 'vt-rescan',
|
||||
status,
|
||||
})
|
||||
accUpdated++
|
||||
|
||||
@@ -30,6 +30,7 @@ Ensure Convex env is set (auth + embeddings):
|
||||
- `OPENAI_API_KEY`
|
||||
- `SITE_URL` (your web app URL)
|
||||
- Optional webhook env (see `docs/webhook.md`)
|
||||
- Optional: `GITHUB_TOKEN` (recommended; raises GitHub account lookup limit used by publish gate)
|
||||
|
||||
## 2) Deploy web app (Vercel)
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ Response:
|
||||
{ "results": [{ "score": 0.123, "slug": "gifgrep", "displayName": "GifGrep", "summary": "…", "version": "1.2.3", "updatedAt": 1730000000000 }] }
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Results are returned in relevance order (embedding similarity + exact slug/name token boosts + popularity prior from downloads).
|
||||
|
||||
### `GET /api/v1/skills`
|
||||
|
||||
Query params:
|
||||
|
||||
@@ -49,3 +49,7 @@ read_when:
|
||||
- `githubFetchedAt` (fetch timestamp)
|
||||
- Cache TTL: 24 hours.
|
||||
- Gate applies to web uploads, CLI publish, and GitHub import.
|
||||
- If GitHub responds `403` or `429`, publish fails with:
|
||||
- `GitHub API rate limit exceeded — please try again in a few minutes`
|
||||
- To reduce rate-limit failures, set `GITHUB_TOKEN` in Convex env for authenticated
|
||||
GitHub API requests.
|
||||
|
||||
@@ -23,6 +23,12 @@ read_when:
|
||||
- Set `OPENAI_API_KEY` in the Convex environment (not only locally).
|
||||
- Re-run `bunx convex dev` / `bunx convex deploy` after setting env.
|
||||
|
||||
## `publish` fails with `GitHub API rate limit exceeded`
|
||||
|
||||
- This is the GitHub account-age gate lookup hitting unauthenticated limits.
|
||||
- Set `GITHUB_TOKEN` in Convex environment to use authenticated GitHub API limits.
|
||||
- Retry publish after a short wait if the limit was already exhausted.
|
||||
|
||||
## `sync` says “No skills found”
|
||||
|
||||
- `sync` looks for folders containing `SKILL.md` (or `skill.md`).
|
||||
|
||||
@@ -20,7 +20,6 @@ const REQUEST_TIMEOUT_MS = 15_000
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
allowH2: true,
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"only-allow": "^1.2.2",
|
||||
"oxlint": "^1.42.0",
|
||||
"oxlint-tsgolint": "^0.11.4",
|
||||
"undici": "^7.19.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawhub",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"description": "ClawHub CLI \\u2014 install, update, search, and publish agent skills.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const chmodMock = vi.fn()
|
||||
const mkdirMock = vi.fn()
|
||||
const readFileMock = vi.fn()
|
||||
const writeFileMock = vi.fn()
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
chmod: (...args: unknown[]) => chmodMock(...args),
|
||||
mkdir: (...args: unknown[]) => mkdirMock(...args),
|
||||
readFile: (...args: unknown[]) => readFileMock(...args),
|
||||
writeFile: (...args: unknown[]) => writeFileMock(...args),
|
||||
}))
|
||||
|
||||
const { writeGlobalConfig } = await import('./config')
|
||||
|
||||
const originalPlatform = process.platform
|
||||
const testConfigPath = '/tmp/clawhub-config-test/config.json'
|
||||
|
||||
function makeErr(code: string): NodeJS.ErrnoException {
|
||||
const error = new Error(code) as NodeJS.ErrnoException
|
||||
error.code = code
|
||||
return error
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('CLAWHUB_CONFIG_PATH', testConfigPath)
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' })
|
||||
chmodMock.mockResolvedValue(undefined)
|
||||
mkdirMock.mockResolvedValue(undefined)
|
||||
readFileMock.mockResolvedValue('')
|
||||
writeFileMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
vi.unstubAllEnvs()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('writeGlobalConfig', () => {
|
||||
it('writes config with restricted modes', async () => {
|
||||
await writeGlobalConfig({ registry: 'https://example.com', token: 'clh_test' })
|
||||
|
||||
expect(mkdirMock).toHaveBeenCalledWith('/tmp/clawhub-config-test', {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
})
|
||||
expect(writeFileMock).toHaveBeenCalledWith(
|
||||
testConfigPath,
|
||||
expect.stringContaining('"token": "clh_test"'),
|
||||
{
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
},
|
||||
)
|
||||
expect(chmodMock).toHaveBeenCalledWith(testConfigPath, 0o600)
|
||||
})
|
||||
|
||||
it('ignores non-fatal chmod errors', async () => {
|
||||
chmodMock.mockRejectedValueOnce(makeErr('ENOTSUP'))
|
||||
|
||||
await expect(writeGlobalConfig({ registry: 'https://example.com' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rethrows unexpected chmod errors', async () => {
|
||||
chmodMock.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
await expect(writeGlobalConfig({ registry: 'https://example.com' })).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
@@ -1,44 +1,51 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { type GlobalConfig, GlobalConfigSchema, parseArk } from './schema/index.js'
|
||||
|
||||
/**
|
||||
* Resolve config path with legacy fallback.
|
||||
* Checks for 'clawhub' first, falls back to legacy 'clawdhub' if it exists.
|
||||
*/
|
||||
function resolveConfigPath(baseDir: string): string {
|
||||
const clawhubPath = join(baseDir, 'clawhub', 'config.json')
|
||||
const clawdhubPath = join(baseDir, 'clawdhub', 'config.json')
|
||||
if (existsSync(clawhubPath)) return clawhubPath
|
||||
if (existsSync(clawdhubPath)) return clawdhubPath
|
||||
return clawhubPath
|
||||
}
|
||||
|
||||
function isNonFatalChmodError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
return code === 'EPERM' || code === 'ENOTSUP' || code === 'EOPNOTSUPP' || code === 'EINVAL'
|
||||
}
|
||||
|
||||
export function getGlobalConfigPath() {
|
||||
const override =
|
||||
process.env.CLAWHUB_CONFIG_PATH?.trim() ?? process.env.CLAWDHUB_CONFIG_PATH?.trim()
|
||||
if (override) return resolve(override)
|
||||
|
||||
const home = homedir()
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const clawhubPath = join(home, 'Library', 'Application Support', 'clawhub', 'config.json')
|
||||
const clawdhubPath = join(home, 'Library', 'Application Support', 'clawdhub', 'config.json')
|
||||
if (existsSync(clawhubPath)) return clawhubPath
|
||||
if (existsSync(clawdhubPath)) return clawdhubPath
|
||||
return clawhubPath
|
||||
return resolveConfigPath(join(home, 'Library', 'Application Support'))
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME
|
||||
if (xdg) {
|
||||
const clawhubPath = join(xdg, 'clawhub', 'config.json')
|
||||
const clawdhubPath = join(xdg, 'clawdhub', 'config.json')
|
||||
if (existsSync(clawhubPath)) return clawhubPath
|
||||
if (existsSync(clawdhubPath)) return clawdhubPath
|
||||
return clawhubPath
|
||||
return resolveConfigPath(xdg)
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const appData = process.env.APPDATA
|
||||
if (appData) {
|
||||
const clawhubPath = join(appData, 'clawhub', 'config.json')
|
||||
const clawdhubPath = join(appData, 'clawdhub', 'config.json')
|
||||
if (existsSync(clawhubPath)) return clawhubPath
|
||||
if (existsSync(clawdhubPath)) return clawdhubPath
|
||||
return clawhubPath
|
||||
return resolveConfigPath(appData)
|
||||
}
|
||||
}
|
||||
const clawhubPath = join(home, '.config', 'clawhub', 'config.json')
|
||||
const clawdhubPath = join(home, '.config', 'clawdhub', 'config.json')
|
||||
if (existsSync(clawhubPath)) return clawhubPath
|
||||
if (existsSync(clawdhubPath)) return clawdhubPath
|
||||
return clawhubPath
|
||||
|
||||
return resolveConfigPath(join(home, '.config'))
|
||||
}
|
||||
|
||||
export async function readGlobalConfig(): Promise<GlobalConfig | null> {
|
||||
@@ -53,6 +60,24 @@ export async function readGlobalConfig(): Promise<GlobalConfig | null> {
|
||||
|
||||
export async function writeGlobalConfig(config: GlobalConfig) {
|
||||
const path = getGlobalConfigPath()
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
|
||||
const dir = dirname(path)
|
||||
|
||||
// Create directory with restricted permissions (owner only)
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
|
||||
// Write file with restricted permissions (owner read/write only)
|
||||
// This protects API tokens from being read by other users
|
||||
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
// Ensure permissions on existing files (writeFile mode only applies on create)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await chmod(path, 0o600)
|
||||
} catch (error) {
|
||||
if (!isNonFatalChmodError(error)) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
try {
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
allowH2: true,
|
||||
connect: { timeout: REQUEST_TIMEOUT_MS },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (config: { beforeLoad?: unknown }) => ({ __config: config }),
|
||||
redirect: (options: unknown) => ({ redirect: options }),
|
||||
}))
|
||||
|
||||
import { Route } from '../routes/search'
|
||||
|
||||
function runBeforeLoad(search: { q?: string; highlighted?: boolean }, hostname = 'clawdhub.com') {
|
||||
const route = Route as unknown as {
|
||||
__config: {
|
||||
beforeLoad?: (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
location: { url: URL }
|
||||
}) => void
|
||||
}
|
||||
}
|
||||
const beforeLoad = route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
location: { url: URL }
|
||||
}) => void
|
||||
let thrown: unknown
|
||||
|
||||
try {
|
||||
beforeLoad({ search, location: { url: new URL(`https://${hostname}/search`) } })
|
||||
} catch (error) {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
return thrown
|
||||
}
|
||||
|
||||
describe('search route', () => {
|
||||
it('redirects skills host to the skills index', () => {
|
||||
expect(runBeforeLoad({ q: 'crab', highlighted: true }, 'clawdhub.com')).toEqual({
|
||||
redirect: {
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: 'crab',
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: true,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects souls host with query to home search', () => {
|
||||
expect(runBeforeLoad({ q: 'crab', highlighted: true }, 'onlycrabs.ai')).toEqual({
|
||||
redirect: {
|
||||
to: '/',
|
||||
search: {
|
||||
q: 'crab',
|
||||
highlighted: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects souls host without query to home with search mode', () => {
|
||||
expect(runBeforeLoad({}, 'onlycrabs.ai')).toEqual({
|
||||
redirect: {
|
||||
to: '/',
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { act, render } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SkillsIndex } from '../routes/skills/index'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const useActionMock = vi.fn()
|
||||
const usePaginatedQueryMock = vi.fn()
|
||||
let searchMock: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => searchMock,
|
||||
}),
|
||||
Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
|
||||
}))
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useAction: (...args: unknown[]) => useActionMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('convex-helpers/react', () => ({
|
||||
usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
|
||||
}))
|
||||
|
||||
describe('SkillsIndex load-more observer', () => {
|
||||
beforeEach(() => {
|
||||
usePaginatedQueryMock.mockReset()
|
||||
useActionMock.mockReset()
|
||||
navigateMock.mockReset()
|
||||
searchMock = {}
|
||||
useActionMock.mockReturnValue(() => Promise.resolve([]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('triggers one request for repeated intersection callbacks', async () => {
|
||||
const loadMorePaginated = vi.fn()
|
||||
usePaginatedQueryMock.mockReturnValue({
|
||||
results: [makeListResult('skill-0', 'Skill 0')],
|
||||
status: 'CanLoadMore',
|
||||
loadMore: loadMorePaginated,
|
||||
})
|
||||
|
||||
type ObserverInstance = {
|
||||
callback: IntersectionObserverCallback
|
||||
observe: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const observers: ObserverInstance[] = []
|
||||
class IntersectionObserverMock {
|
||||
callback: IntersectionObserverCallback
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
takeRecords = vi.fn(() => [])
|
||||
root = null
|
||||
rootMargin = '0px'
|
||||
thresholds: number[] = []
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback
|
||||
observers.push(this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
IntersectionObserverMock as unknown as typeof IntersectionObserver,
|
||||
)
|
||||
|
||||
render(<SkillsIndex />)
|
||||
|
||||
expect(observers).toHaveLength(1)
|
||||
const observer = observers[0]
|
||||
const entries = [{ isIntersecting: true }] as Array<IntersectionObserverEntry>
|
||||
|
||||
await act(async () => {
|
||||
observer.callback(entries, observer as unknown as IntersectionObserver)
|
||||
observer.callback(entries, observer as unknown as IntersectionObserver)
|
||||
observer.callback(entries, observer as unknown as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(loadMorePaginated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
function makeListResult(slug: string, displayName: string) {
|
||||
return {
|
||||
skill: {
|
||||
_id: `skill_${slug}`,
|
||||
slug,
|
||||
displayName,
|
||||
summary: `${displayName} summary`,
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
latestVersion: null,
|
||||
ownerHandle: null,
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,30 @@ describe('SkillsIndex', () => {
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses relevance as default sort when searching', async () => {
|
||||
searchMock = { q: 'notion' }
|
||||
const actionFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
makeSearchResult('newer-low-score', 'Newer Low Score', 0.1, 2000),
|
||||
makeSearchResult('older-high-score', 'Older High Score', 0.9, 1000),
|
||||
])
|
||||
useActionMock.mockReturnValue(actionFn)
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<SkillsIndex />)
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync()
|
||||
})
|
||||
|
||||
const titles = Array.from(
|
||||
document.querySelectorAll('.skills-row-title > span:first-child'),
|
||||
).map((node) => node.textContent)
|
||||
|
||||
expect(titles[0]).toBe('Older High Score')
|
||||
expect(titles[1]).toBe('Newer Low Score')
|
||||
})
|
||||
})
|
||||
|
||||
function makeSearchResults(count: number) {
|
||||
@@ -143,3 +167,27 @@ function makeSearchResults(count: number) {
|
||||
version: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function makeSearchResult(slug: string, displayName: string, score: number, createdAt: number) {
|
||||
return {
|
||||
score,
|
||||
skill: {
|
||||
_id: `skill_${slug}`,
|
||||
slug,
|
||||
displayName,
|
||||
summary: `${displayName} summary`,
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
},
|
||||
version: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawhub-schema'
|
||||
import { useAction, useMutation, useQuery } from 'convex/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
@@ -10,7 +10,10 @@ import { getSkillBadges } from '../lib/badges'
|
||||
import type { PublicSkill, PublicUser } from '../lib/publicUser'
|
||||
import { canManageSkill, isModerator } from '../lib/roles'
|
||||
import { useAuthStatus } from '../lib/useAuthStatus'
|
||||
import { SkillDiffCard } from './SkillDiffCard'
|
||||
|
||||
const SkillDiffCard = lazy(() =>
|
||||
import('./SkillDiffCard').then((m) => ({ default: m.SkillDiffCard })),
|
||||
)
|
||||
|
||||
type VtAnalysis = {
|
||||
status: string
|
||||
@@ -132,23 +135,17 @@ function LlmAnalysisDetail({ analysis }: { analysis: LlmAnalysis }) {
|
||||
|
||||
return (
|
||||
<div className={`analysis-detail${isOpen ? ' is-open' : ''}`}>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className="analysis-detail-header"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsOpen((prev) => !prev)
|
||||
}
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="analysis-summary-text">{analysis.summary}</span>
|
||||
<span className="analysis-detail-toggle">
|
||||
Details <span className="chevron">{'\u25BE'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="analysis-body">
|
||||
{analysis.dimensions && analysis.dimensions.length > 0 ? (
|
||||
<div className="analysis-dimensions">
|
||||
@@ -169,11 +166,18 @@ function LlmAnalysisDetail({ analysis }: { analysis: LlmAnalysis }) {
|
||||
{analysis.findings ? (
|
||||
<div className="scan-findings-section">
|
||||
<div className="scan-findings-title">Scan Findings in Context</div>
|
||||
{analysis.findings.split('\n').map((line, i) => (
|
||||
<div key={i} className="scan-finding-row">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const counts = new Map<string, number>()
|
||||
return analysis.findings.split('\n').map((line) => {
|
||||
const count = (counts.get(line) ?? 0) + 1
|
||||
counts.set(line, count)
|
||||
return (
|
||||
<div key={`${line}-${count}`} className="scan-finding-row">
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{analysis.guidance ? (
|
||||
@@ -580,7 +584,11 @@ export function SkillDetailPage({
|
||||
{canManage ? (
|
||||
<p className="pending-banner-appeal">
|
||||
If you believe this skill has been incorrectly flagged, please{' '}
|
||||
<a href="https://github.com/openclaw/clawhub/issues" target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href="https://github.com/openclaw/clawhub/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
submit an issue on GitHub
|
||||
</a>{' '}
|
||||
and we'll break down why it was flagged and what you can do.
|
||||
@@ -1002,7 +1010,9 @@ export function SkillDetailPage({
|
||||
) : null}
|
||||
{activeTab === 'compare' && skill ? (
|
||||
<div className="tab-body">
|
||||
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
||||
<Suspense fallback={<div className="stat">Loading diff viewer…</div>}>
|
||||
<SkillDiffCard skill={skill} versions={diffVersions ?? []} variant="embedded" />
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab === 'versions' ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UploadRouteImport } from './routes/upload'
|
||||
import { Route as StarsRouteImport } from './routes/stars'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as SearchRouteImport } from './routes/search'
|
||||
import { Route as ManagementRouteImport } from './routes/management'
|
||||
import { Route as ImportRouteImport } from './routes/import'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
@@ -39,6 +40,11 @@ const SettingsRoute = SettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SearchRoute = SearchRouteImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ManagementRoute = ManagementRouteImport.update({
|
||||
id: '/management',
|
||||
path: '/management',
|
||||
@@ -101,6 +107,7 @@ export interface FileRoutesByFullPath {
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
@@ -117,6 +124,7 @@ export interface FileRoutesByTo {
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
@@ -134,6 +142,7 @@ export interface FileRoutesById {
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/import': typeof ImportRoute
|
||||
'/management': typeof ManagementRoute
|
||||
'/search': typeof SearchRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/stars': typeof StarsRoute
|
||||
'/upload': typeof UploadRoute
|
||||
@@ -152,6 +161,7 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
| '/upload'
|
||||
@@ -168,6 +178,7 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
| '/upload'
|
||||
@@ -184,6 +195,7 @@ export interface FileRouteTypes {
|
||||
| '/dashboard'
|
||||
| '/import'
|
||||
| '/management'
|
||||
| '/search'
|
||||
| '/settings'
|
||||
| '/stars'
|
||||
| '/upload'
|
||||
@@ -201,6 +213,7 @@ export interface RootRouteChildren {
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
ImportRoute: typeof ImportRoute
|
||||
ManagementRoute: typeof ManagementRoute
|
||||
SearchRoute: typeof SearchRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
StarsRoute: typeof StarsRoute
|
||||
UploadRoute: typeof UploadRoute
|
||||
@@ -235,6 +248,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/search': {
|
||||
id: '/search'
|
||||
path: '/search'
|
||||
fullPath: '/search'
|
||||
preLoaderRoute: typeof SearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/management': {
|
||||
id: '/management'
|
||||
path: '/management'
|
||||
@@ -321,6 +341,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
DashboardRoute: DashboardRoute,
|
||||
ImportRoute: ImportRoute,
|
||||
ManagementRoute: ManagementRoute,
|
||||
SearchRoute: SearchRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
StarsRoute: StarsRoute,
|
||||
UploadRoute: UploadRoute,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { detectSiteMode } from '../lib/site'
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
validateSearch: (search) => ({
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
||||
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
|
||||
}),
|
||||
beforeLoad: ({ search, location }) => {
|
||||
const hostname =
|
||||
(location as { url?: URL }).url?.hostname ??
|
||||
(typeof window !== 'undefined' ? window.location.hostname : undefined)
|
||||
const mode = detectSiteMode(hostname)
|
||||
if (mode === 'skills') {
|
||||
throw redirect({
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
view: undefined,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
throw redirect({
|
||||
to: '/',
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
highlighted: undefined,
|
||||
search: search.q ? undefined : true,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -8,7 +8,15 @@ import { SkillCard } from '../../components/SkillCard'
|
||||
import { getSkillBadges, isSkillHighlighted } from '../../lib/badges'
|
||||
import type { PublicSkill } from '../../lib/publicUser'
|
||||
|
||||
const sortKeys = ['newest', 'downloads', 'installs', 'stars', 'name', 'updated'] as const
|
||||
const sortKeys = [
|
||||
'relevance',
|
||||
'newest',
|
||||
'downloads',
|
||||
'installs',
|
||||
'stars',
|
||||
'name',
|
||||
'updated',
|
||||
] as const
|
||||
const pageSize = 25
|
||||
type SortKey = (typeof sortKeys)[number]
|
||||
type SortDir = 'asc' | 'desc'
|
||||
@@ -28,6 +36,7 @@ type SkillListEntry = {
|
||||
skill: PublicSkill
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle?: string | null
|
||||
searchScore?: number
|
||||
}
|
||||
|
||||
type SkillSearchEntry = {
|
||||
@@ -62,21 +71,25 @@ export const Route = createFileRoute('/skills/')({
|
||||
export function SkillsIndex() {
|
||||
const navigate = Route.useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const sort = search.sort ?? 'newest'
|
||||
const dir = parseDir(search.dir, sort)
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const view = search.view ?? 'list'
|
||||
const highlightedOnly = search.highlighted ?? false
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
|
||||
const [searchLimit, setSearchLimit] = useState(pageSize)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const searchRequest = useRef(0)
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null)
|
||||
const loadMoreInFlightRef = useRef(false)
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const trimmedQuery = useMemo(() => query.trim(), [query])
|
||||
const hasQuery = trimmedQuery.length > 0
|
||||
const sort =
|
||||
search.sort === 'relevance' && !hasQuery
|
||||
? 'newest'
|
||||
: (search.sort ?? (hasQuery ? 'relevance' : 'newest'))
|
||||
const dir = parseDir(search.dir, sort)
|
||||
const searchKey = trimmedQuery ? `${trimmedQuery}::${highlightedOnly ? '1' : '0'}` : ''
|
||||
|
||||
// Use convex-helpers usePaginatedQuery for better cache behavior
|
||||
@@ -149,6 +162,7 @@ export function SkillsIndex() {
|
||||
skill: entry.skill,
|
||||
latestVersion: entry.version,
|
||||
ownerHandle: entry.ownerHandle ?? null,
|
||||
searchScore: entry.score,
|
||||
}))
|
||||
}
|
||||
// paginatedResults is an array of page items from usePaginatedQuery
|
||||
@@ -165,6 +179,8 @@ export function SkillsIndex() {
|
||||
const results = [...filtered]
|
||||
results.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case 'relevance':
|
||||
return ((a.searchScore ?? 0) - (b.searchScore ?? 0)) * multiplier
|
||||
case 'downloads':
|
||||
return (a.skill.stats.downloads - b.skill.stats.downloads) * multiplier
|
||||
case 'installs':
|
||||
@@ -196,7 +212,8 @@ export function SkillsIndex() {
|
||||
const canAutoLoad = typeof IntersectionObserver !== 'undefined'
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (isLoadingMore || !canLoadMore) return
|
||||
if (loadMoreInFlightRef.current || isLoadingMore || !canLoadMore) return
|
||||
loadMoreInFlightRef.current = true
|
||||
if (hasQuery) {
|
||||
setSearchLimit((value) => value + pageSize)
|
||||
} else {
|
||||
@@ -204,6 +221,12 @@ export function SkillsIndex() {
|
||||
}
|
||||
}, [canLoadMore, hasQuery, isLoadingMore, loadMorePaginated])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingMore) {
|
||||
loadMoreInFlightRef.current = false
|
||||
}
|
||||
}, [isLoadingMore])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canLoadMore || typeof IntersectionObserver === 'undefined') return
|
||||
const target = loadMoreRef.current
|
||||
@@ -211,6 +234,7 @@ export function SkillsIndex() {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
observer.disconnect()
|
||||
loadMore()
|
||||
}
|
||||
},
|
||||
@@ -284,6 +308,7 @@ export function SkillsIndex() {
|
||||
}}
|
||||
aria-label="Sort skills"
|
||||
>
|
||||
{hasQuery ? <option value="relevance">Relevance</option> : null}
|
||||
<option value="newest">Newest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="downloads">Downloads</option>
|
||||
|
||||
+14
-3
@@ -1259,6 +1259,8 @@ code {
|
||||
.skill-detail-stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.skill-hero {
|
||||
@@ -1699,6 +1701,8 @@ code {
|
||||
|
||||
.tab-card {
|
||||
gap: 14px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
@@ -1731,11 +1735,14 @@ code {
|
||||
.tab-body {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
@@ -1751,6 +1758,7 @@ code {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -1766,6 +1774,7 @@ code {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -2350,6 +2359,7 @@ code {
|
||||
|
||||
.markdown {
|
||||
line-height: 1.7;
|
||||
max-width: 100%;
|
||||
color: #3f3a34;
|
||||
}
|
||||
|
||||
@@ -2376,6 +2386,7 @@ code {
|
||||
|
||||
.markdown pre {
|
||||
white-space: pre;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 250, 247, 0.88));
|
||||
@@ -3206,9 +3217,9 @@ html.theme-transition::view-transition-new(theme) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pending-banner-appeal {
|
||||
margin-top: 6px !important;
|
||||
font-size: 0.8rem !important;
|
||||
.pending-banner-content .pending-banner-appeal {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user