-
Notifications
You must be signed in to change notification settings - Fork 85
feat: source filter for Available Skills + platform catalog API #8097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,14 @@ | ||
| import { readFileSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| import { gunzipSync } from 'node:zlib'; | ||
|
|
||
| import type { CatalogEntry } from '../tools/skills/vellum-catalog.js'; | ||
| import { getLogger } from '../util/logger.js'; | ||
| import { readPlatformToken } from '../util/platform.js'; | ||
|
|
||
| const log = getLogger('vellum-catalog-remote'); | ||
|
|
||
| const GITHUB_RAW_BASE = | ||
| 'https://raw.githubusercontent.com/vellum-ai/vellum-assistant/main/assistant/src/config/vellum-skills'; | ||
| const PLATFORM_URL = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? 'https://assistant.vellum.ai'; | ||
|
|
||
| const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour | ||
|
|
||
|
|
@@ -43,16 +44,30 @@ function getBundledSkillContent(skillId: string): string | null { | |
| } | ||
| } | ||
|
|
||
| /** Fetch catalog entries (cached, async). Falls back to bundled copy. */ | ||
| /** Build request headers, including platform token when available. */ | ||
| function buildPlatformHeaders(): Record<string, string> { | ||
| const headers: Record<string, string> = {}; | ||
| const token = readPlatformToken(); | ||
| if (token) { | ||
| headers['X-Session-Token'] = token; | ||
| } | ||
| return headers; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch catalog entries from the platform API. Falls back to bundled copy. | ||
| * Reads the platform token from ~/.vellum/platform-token automatically. | ||
| */ | ||
| export async function fetchCatalogEntries(): Promise<CatalogEntry[]> { | ||
| const now = Date.now(); | ||
| if (cachedEntries && now - cacheTimestamp < CACHE_TTL_MS) { | ||
| return cachedEntries; | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
|
|
||
| try { | ||
| const url = `${GITHUB_RAW_BASE}/catalog.json`; | ||
| const url = `${PLATFORM_URL}/v1/skills/`; | ||
| const response = await fetch(url, { | ||
| headers: buildPlatformHeaders(), | ||
| signal: AbortSignal.timeout(5000), | ||
| }); | ||
|
|
||
|
|
@@ -63,14 +78,14 @@ export async function fetchCatalogEntries(): Promise<CatalogEntry[]> { | |
| const manifest: CatalogManifest = await response.json(); | ||
| const skills = manifest.skills; | ||
| if (!Array.isArray(skills) || skills.length === 0) { | ||
| throw new Error('Remote catalog has invalid or empty skills array'); | ||
| throw new Error('Platform catalog has invalid or empty skills array'); | ||
| } | ||
| cachedEntries = skills; | ||
| cacheTimestamp = now; | ||
| log.info({ count: cachedEntries.length }, 'Fetched remote vellum-skills catalog'); | ||
| log.info({ count: cachedEntries.length }, 'Fetched vellum-skills catalog from platform API'); | ||
| return cachedEntries; | ||
| } catch (err) { | ||
| log.warn({ err }, 'Failed to fetch remote catalog, falling back to bundled copy'); | ||
| log.warn({ err }, 'Failed to fetch catalog from platform API, falling back to bundled copy'); | ||
| const bundled = loadBundledCatalog(); | ||
| // Cache the bundled result too so we don't re-fetch on every call during outage | ||
| cachedEntries = bundled; | ||
|
|
@@ -79,28 +94,72 @@ export async function fetchCatalogEntries(): Promise<CatalogEntry[]> { | |
| } | ||
| } | ||
|
|
||
| /** Fetch a skill's SKILL.md content from GitHub. Falls back to bundled copy. */ | ||
| /** | ||
| * Extract SKILL.md content from a tar archive (uncompressed). | ||
| * Tar format: 512-byte header blocks followed by file data blocks. | ||
| */ | ||
| function extractSkillMdFromTar(tarBuffer: Buffer): string | null { | ||
| let offset = 0; | ||
| while (offset + 512 <= tarBuffer.length) { | ||
| const header = tarBuffer.subarray(offset, offset + 512); | ||
|
|
||
| // Check for end-of-archive (two consecutive zero blocks) | ||
| if (header.every((b) => b === 0)) break; | ||
|
|
||
| // Extract filename (bytes 0-99, null-terminated) | ||
| const nameEnd = header.indexOf(0, 0); | ||
| const name = header.subarray(0, Math.min(nameEnd >= 0 ? nameEnd : 100, 100)).toString('utf-8'); | ||
|
|
||
| // Extract file size (bytes 124-135, octal string) | ||
| const sizeStr = header.subarray(124, 136).toString('utf-8').trim(); | ||
| const size = parseInt(sizeStr, 8) || 0; | ||
|
|
||
| offset += 512; // move past header | ||
|
|
||
| if (name.endsWith('SKILL.md') || name === 'SKILL.md') { | ||
| return tarBuffer.subarray(offset, offset + size).toString('utf-8'); | ||
| } | ||
|
|
||
| // Skip to next header (data blocks are padded to 512 bytes) | ||
| offset += Math.ceil(size / 512) * 512; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch a skill's SKILL.md content from the platform tar API. | ||
| * GET /v1/skills/{skill_id}/ returns a tar.gz archive containing all skill files. | ||
| * Falls back to bundled copy on failure. | ||
| */ | ||
| export async function fetchSkillContent(skillId: string): Promise<string | null> { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this method is just a pass through to |
||
| try { | ||
| const url = `${GITHUB_RAW_BASE}/${encodeURIComponent(skillId)}/SKILL.md`; | ||
| const url = `${PLATFORM_URL}/v1/skills/${encodeURIComponent(skillId)}/`; | ||
| const response = await fetch(url, { | ||
| signal: AbortSignal.timeout(10000), | ||
| headers: buildPlatformHeaders(), | ||
| signal: AbortSignal.timeout(15000), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | ||
| } | ||
|
|
||
| const content = await response.text(); | ||
| log.info({ skillId }, 'Fetched remote SKILL.md'); | ||
| return content; | ||
| const gzipBuffer = Buffer.from(await response.arrayBuffer()); | ||
| const tarBuffer = gunzipSync(gzipBuffer); | ||
| const skillMd = extractSkillMdFromTar(tarBuffer); | ||
|
|
||
| if (skillMd) { | ||
| return skillMd; | ||
| } | ||
|
|
||
| log.warn({ skillId }, 'SKILL.md not found in platform tar archive, falling back to bundled'); | ||
| } catch (err) { | ||
| log.warn({ err, skillId }, 'Failed to fetch remote SKILL.md, falling back to bundled copy'); | ||
| return getBundledSkillContent(skillId); | ||
| log.warn({ err, skillId }, 'Failed to fetch skill content from platform API, falling back to bundled'); | ||
| } | ||
|
|
||
| return getBundledSkillContent(skillId); | ||
| } | ||
|
|
||
| /** Check if a skill ID exists in the remote catalog. */ | ||
| /** Check if a skill ID exists in the catalog. */ | ||
| export async function checkVellumSkill(skillId: string): Promise<boolean> { | ||
| const entries = await fetchCatalogEntries(); | ||
| return entries.some((e) => e.id === skillId); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -141,19 +141,23 @@ struct AgentPanelContent: View { | |
| // MARK: - Available Skills Tab | ||
|
|
||
| /// ClaWHub skills filtered to exclude already-installed ones, with local search and sort. | ||
| /// When filtering to Vellum-only, installed Vellum skills are kept so the catalog is always visible. | ||
| private var availableClawhubSkills: [ClawhubSkillItem] { | ||
| let installedNames = Set(skillsManager.skills.map(\.name)) | ||
| var filtered = skillsManager.searchResults | ||
| .filter { !installedNames.contains($0.name) } | ||
|
|
||
| // Source filter | ||
| // Source filter — applied before installed-name exclusion so we can | ||
| // relax the exclusion for Vellum-only view. | ||
| var filtered: [ClawhubSkillItem] | ||
| switch skillSourceFilter { | ||
| case .all: | ||
| break | ||
| filtered = skillsManager.searchResults | ||
| .filter { !installedNames.contains($0.name) } | ||
| case .vellum: | ||
| filtered = filtered.filter { $0.isVellum } | ||
| // Show all Vellum catalog skills even if already installed | ||
| filtered = skillsManager.searchResults.filter { $0.isVellum } | ||
|
Comment on lines
155
to
+157
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This change now surfaces already-installed Vellum skills in Available, but those entries still open the detail screen where Useful? React with 👍 / 👎. |
||
| case .community: | ||
| filtered = filtered.filter { !$0.isVellum } | ||
| filtered = skillsManager.searchResults | ||
| .filter { !$0.isVellum && !installedNames.contains($0.name) } | ||
| } | ||
|
|
||
| // Local fuzzy filter by name/description | ||
|
|
@@ -269,7 +273,7 @@ struct AgentPanelContent: View { | |
| title: hasActiveSearch ? "No matches in Available" : "No results", | ||
| subtitle: hasActiveSearch | ||
| ? "No available skills matched \"\(globalSkillSearchQuery)\"" | ||
| : "No \(skillSourceFilter.rawValue.lowercased()) skills found", | ||
| : "No \(skillSourceFilter.rawValue) skills found", | ||
| icon: "magnifyingglass" | ||
| ) | ||
|
|
||
|
|
@@ -287,24 +291,26 @@ struct AgentPanelContent: View { | |
| .frame(minHeight: 100) | ||
| } | ||
|
|
||
| // Community disclaimer | ||
| VStack(spacing: VSpacing.sm) { | ||
| HStack(spacing: VSpacing.sm) { | ||
| Image(systemName: "exclamationmark.shield.fill") | ||
| .font(.system(size: 10)) | ||
| .foregroundColor(Amber._500) | ||
| Text("Community skills are not verified by Vellum. Review before installing.") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.textMuted) | ||
| } | ||
| // Community disclaimer — hidden when filtering to Vellum-only | ||
| if skillSourceFilter != .vellum { | ||
| VStack(spacing: VSpacing.sm) { | ||
| HStack(spacing: VSpacing.sm) { | ||
| Image(systemName: "exclamationmark.shield.fill") | ||
| .font(.system(size: 10)) | ||
| .foregroundColor(Amber._500) | ||
| Text("Community skills are not verified by Vellum. Review before installing.") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.textMuted) | ||
| } | ||
|
|
||
| HStack(spacing: VSpacing.sm) { | ||
| Image(systemName: "sparkles") | ||
| .font(.system(size: 10)) | ||
| .foregroundColor(VColor.accent) | ||
| Text("Browse more on ClawhHub") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.textMuted) | ||
| HStack(spacing: VSpacing.sm) { | ||
| Image(systemName: "sparkles") | ||
| .font(.system(size: 10)) | ||
| .foregroundColor(VColor.accent) | ||
| Text("Browse more on ClawhHub") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.textMuted) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -352,6 +358,8 @@ struct AgentPanelContent: View { | |
| } | ||
|
|
||
| private func clawhubSkillCard(_ skill: ClawhubSkillItem) -> some View { | ||
| let installedNames = Set(skillsManager.skills.map(\.name)) | ||
| let isAlreadyInstalled = installedNames.contains(skill.name) | ||
| let isInstalling = installingSlug == skill.slug | ||
| let isNew = !skill.isVellum && skill.createdAt > 0 && Date().timeIntervalSince( | ||
| Date(timeIntervalSince1970: Double(skill.createdAt) / 1000) | ||
|
|
@@ -392,24 +400,30 @@ struct AgentPanelContent: View { | |
|
|
||
| Spacer() | ||
|
|
||
| VButton( | ||
| label: isInstalling ? "Installing..." : "Install", | ||
| icon: isInstalling ? nil : "arrow.down.circle.fill", | ||
| style: .primary, | ||
| isDisabled: installingSlug != nil | ||
| ) { | ||
| guard installingSlug == nil else { return } | ||
| let attemptId = UUID() | ||
| installingSlug = skill.slug | ||
| installAttemptId = attemptId | ||
| skillsManager.installSkill(slug: skill.slug) | ||
| installTimeoutTask?.cancel() | ||
| installTimeoutTask = Task { | ||
| try? await Task.sleep(nanoseconds: 10_000_000_000) | ||
| guard !Task.isCancelled else { return } | ||
| if installingSlug == skill.slug && installAttemptId == attemptId { | ||
| installingSlug = nil | ||
| installAttemptId = nil | ||
| if isAlreadyInstalled { | ||
| Text("Installed") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.success) | ||
| } else { | ||
| VButton( | ||
| label: isInstalling ? "Installing..." : "Install", | ||
| icon: isInstalling ? nil : "arrow.down.circle.fill", | ||
| style: .primary, | ||
| isDisabled: installingSlug != nil | ||
| ) { | ||
| guard installingSlug == nil else { return } | ||
| let attemptId = UUID() | ||
| installingSlug = skill.slug | ||
| installAttemptId = attemptId | ||
| skillsManager.installSkill(slug: skill.slug) | ||
| installTimeoutTask?.cancel() | ||
| installTimeoutTask = Task { | ||
| try? await Task.sleep(nanoseconds: 10_000_000_000) | ||
| guard !Task.isCancelled else { return } | ||
| if installingSlug == skill.slug && installAttemptId == attemptId { | ||
| installingSlug = nil | ||
| installAttemptId = nil | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -823,11 +837,25 @@ struct AgentPanelContent: View { | |
| } | ||
| .frame(minHeight: 100) | ||
| } else { | ||
| Text("No skills installed") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.textMuted) | ||
| VStack(spacing: VSpacing.md) { | ||
| Text("No skills installed") | ||
| .font(VFont.caption) | ||
| .foregroundColor(VColor.textMuted) | ||
| .frame(maxWidth: .infinity, alignment: .leading) | ||
|
|
||
| Button(action: { skillsManager.fetchSkills() }) { | ||
| HStack(spacing: VSpacing.xs) { | ||
| Image(systemName: "arrow.clockwise") | ||
| .font(.system(size: 11)) | ||
| Text("Refresh") | ||
| .font(VFont.caption) | ||
| } | ||
| .foregroundColor(VColor.accent) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .frame(maxWidth: .infinity, alignment: .leading) | ||
| .padding(.vertical, VSpacing.sm) | ||
| } | ||
| .padding(.vertical, VSpacing.sm) | ||
| } | ||
| } else { | ||
| VStack(spacing: VSpacing.md) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
fetchCatalogEntries, the cache is returned before consideringsessionToken, so a prior unauthenticated/bundled result is reused for authenticated requests (and vice versa) for up toCACHE_TTL_MS. In practice, if the first search happens before login and falls back to bundled data, users who log in afterward will continue to see the stale limited catalog until TTL expiry or daemon restart, which breaks the new authenticated catalog flow.Useful? React with 👍 / 👎.