diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e6761ee18..b0047dbd48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -451,6 +451,8 @@ prerequisites: # Optional legacy runtime requirements commands: [curl, jq] # Advisory only; does not hide the skill metadata: hermes: + editorial_name: My Skill # Optional human-readable UI title + editorial_description: What this skill helps a person accomplish. tags: [Category, Subcategory, Keywords] related_skills: [other-skill-name] fallback_for_toolsets: [web] # Optional — show only when toolset is unavailable @@ -483,6 +485,12 @@ Known failure modes and how to handle them. How the agent confirms it worked. ``` +`metadata.hermes.editorial_name` and `editorial_description` are optional, +human-facing presentation copy. They may use natural titles and fuller prose +than the routing-focused top-level fields. Hermes continues to identify and +route skills with `name` and `description`; UIs fall back to that canonical +pair when editorial copy is absent. + ### Platform-specific skills Skills can declare which OS platforms they support via the `platforms` frontmatter field. Skills with this field are automatically hidden from the system prompt, `skills_list()`, and slash commands on incompatible platforms. diff --git a/agent/learn_prompt.py b/agent/learn_prompt.py index db50f95aa3..676007af7f 100644 --- a/agent/learn_prompt.py +++ b/agent/learn_prompt.py @@ -38,6 +38,12 @@ Frontmatter: cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only when the dependency is genuinely platform-bound. Omit the field for portable skills. +- metadata.hermes.editorial_name: a concise, human-readable title for app + surfaces. Use normal title casing and spaces; this is presentation copy, not + the agent-facing skill identifier. +- metadata.hermes.editorial_description: one or two plain-language sentences + explaining the skill to a person browsing it. This is presentation copy and + does not replace the routing-focused top-level description. - metadata.hermes.tags: a few Capitalized, Relevant, Tags. Body section order (omit a section only if it genuinely has no content): diff --git a/agent/skill_utils.py b/agent/skill_utils.py index fe0b06c69f..9c06c05df2 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -33,6 +33,12 @@ ORG_ACTIVE_MARKER = ".active_org" ORG_PROVENANCE_FILE = ".org-provenance.json" ORG_BASELINE_FILE = ".org-baseline.json" # upstream fingerprint; detects local edits +# Collective Wisdom managed installs are intentionally separate from the M2 +# whole-org mirror. The only writer of this marker is the Wisdom setup/client +# path after the Gateway has accepted the profile's installation identity. +WISDOM_MANAGED_DIR_NAME = "_wisdom" +WISDOM_ACTIVE_MARKER = ".active_org" + def read_active_org_id(skills_dir: Path) -> Optional[str]: """The org id whose mirror may resolve, or None (no org skills load).""" @@ -43,6 +49,27 @@ def read_active_org_id(skills_dir: Path) -> Optional[str]: return None +def read_active_wisdom_org_id(skills_dir: Path) -> Optional[str]: + """The last Gateway-verified org whose managed Wisdom skills may load.""" + try: + marker = skills_dir / WISDOM_MANAGED_DIR_NAME / WISDOM_ACTIVE_MARKER + if not marker.exists(): + return None + value = marker.read_text(encoding="utf-8").strip() + return value or None + except OSError: + return None + + +def is_wisdom_managed_path(path, skills_dir: Path) -> bool: + """True when *path* is below ``_wisdom//``.""" + try: + rel = Path(path).resolve().relative_to(Path(skills_dir).resolve()) + except (OSError, ValueError): + return False + return bool(rel.parts) and rel.parts[0] == WISDOM_MANAGED_DIR_NAME + + def _org_rel_parts(path, skills_dir: Path) -> Tuple[str, ...]: """Path parts of *path* relative to *skills_dir* if it is under ``_org/``, else ``()``.""" try: @@ -733,22 +760,113 @@ def is_skill_description_truncated_for_prompt(frontmatter: Dict[str, Any]) -> bo return len(_normalize_skill_description(frontmatter)) > SKILL_PROMPT_DESC_LIMIT +def extract_skill_editorial_metadata( + frontmatter: Dict[str, Any], + *, + fallback_name: str, + fallback_description: str, +) -> Dict[str, str]: + """Resolve optional human-facing skill copy without changing agent metadata. + + ``name`` and ``description`` remain the canonical agent-facing routing + fields. Hermes UIs may use the optional values under ``metadata.hermes``; + older and third-party skills fall back to the canonical pair. + """ + metadata = frontmatter.get("metadata") + hermes = metadata.get("hermes") if isinstance(metadata, dict) else None + if not isinstance(hermes, dict): + hermes = {} + + editorial_name = hermes.get("editorial_name") + editorial_description = hermes.get("editorial_description") + return { + "editorial_name": ( + editorial_name.strip() + if isinstance(editorial_name, str) and editorial_name.strip() + else fallback_name + ), + "editorial_description": ( + editorial_description.strip() + if isinstance(editorial_description, str) + and editorial_description.strip() + else fallback_description + ), + } + + +def load_skill_editorial_metadata( + skill_path: Path, + *, + fallback_name: str | None = None, + fallback_description: str = "", +) -> Dict[str, str]: + """Load human-facing copy from a skill directory with safe fallbacks.""" + canonical_name = fallback_name or skill_path.name + canonical_description = fallback_description + try: + frontmatter, _body = parse_frontmatter( + (skill_path / "SKILL.md").read_text(encoding="utf-8") + ) + name = frontmatter.get("name") + description = frontmatter.get("description") + if isinstance(name, str) and name.strip(): + canonical_name = name.strip() + if isinstance(description, str) and description.strip(): + canonical_description = description.strip() + return extract_skill_editorial_metadata( + frontmatter, + fallback_name=canonical_name, + fallback_description=canonical_description, + ) + except (OSError, UnicodeError, ValueError): + return { + "editorial_name": canonical_name, + "editorial_description": canonical_description, + } + + +# ── File iteration ──────────────────────────────────────────────────────── + + def iter_skill_index_files(skills_dir: Path, filename: str): - """Walk skills_dir yielding sorted paths matching *filename*; prunes - EXCLUDED_SKILL_DIRS and support dirs of skill roots. Org mirrors are - TOKEN-GATED: only the active org's subdir is walked, so leaving an org - stops its skills resolving without manual cleanup.""" + """Walk skills_dir yielding sorted paths matching *filename*. + + Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill + support directories. Support directories (references/templates/assets/ + scripts) can contain arbitrary markdown and even archived package + ``SKILL.md`` files, but they are progressive-disclosure data loaded through + ``skill_view(..., file_path=...)`` rather than active skill roots. + + M2 org mirrors (``_org/``) and Collective Wisdom installs + (``_wisdom/``): TOKEN-GATED resolution. Only the active org's + subdir (per the sync-client-written ``.active_org`` marker) is walked; + every other ``_org//`` (stale mirror from a previous org, or no + marker at all) is pruned — leave an org and its skills stop resolving, + without any manual cleanup. + """ skills_dir_str = str(skills_dir) active_org = read_active_org_id(skills_dir) + active_wisdom_org = read_active_wisdom_org_id(skills_dir) org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME) + wisdom_root = os.path.join(skills_dir_str, WISDOM_MANAGED_DIR_NAME) matches: list[str] = [] for root, dirs, files in os.walk(skills_dir_str, followlinks=True): has_skill_md = "SKILL.md" in files if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None: dirs.remove(ORG_MIRROR_DIR_NAME) + if root == skills_dir_str and WISDOM_MANAGED_DIR_NAME in dirs and active_wisdom_org is None: + dirs.remove(WISDOM_MANAGED_DIR_NAME) elif root == org_root: dirs[:] = [d for d in dirs if d == active_org] - dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS and not (has_skill_md and d in SKILL_SUPPORT_DIRS)] + elif root == wisdom_root: + # Inside _wisdom/: descend ONLY into the last Gateway-verified org. + dirs[:] = [d for d in dirs if d == active_wisdom_org] + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] if filename in files: matches.append(os.path.join(root, filename)) yield from map(Path, sorted(matches)) diff --git a/apps/desktop/src/api/wisdom.ts b/apps/desktop/src/api/wisdom.ts new file mode 100644 index 0000000000..5e46ceb9bb --- /dev/null +++ b/apps/desktop/src/api/wisdom.ts @@ -0,0 +1,577 @@ +import type { WisdomMuteControl, WisdomMuteDuration, WisdomMuteSnapshot, WisdomSyncSnapshot } from '@hermes/shared' + +import type { ActionResponse } from '@/types/hermes' + +import { capabilityScoped, type ProfileScope } from './client' + +export type WisdomReviewStatus = 'advisory' | 'blocked' | 'pass' | 'pending' | 'retry' | 'running' | 'unavailable' + +export interface WisdomReviewCheckRow { + key: string + label?: string + status: WisdomReviewStatus + finding_count: number + details: string[] +} + +export interface WisdomReviewCheck { + source?: 'local_preflight' | string + schema_version?: number + status: WisdomReviewStatus + summary?: string + checks?: WisdomReviewCheckRow[] + provenance?: { kind: 'agent_assessed'; model: null | string; provider: null | string } +} + +export interface WisdomEntitlement { + entitled: boolean + org_id: null | string + scopes: string[] + expires_at: null | number +} + +export interface WisdomStatus { + configured: boolean + setup_required_reason?: 'not_configured' | 'organization_changed' | null + gateway_available: boolean + capability_advertised: boolean + verified_org_id: null | string + authenticated_org_id?: null | string + display_scopes: string[] + error?: null | string +} + +export interface WisdomCandidate { + local_skill_id: string + name: string + editorial_name?: string + editorial_description?: string + path: string + content_hash: string + eligibility: 'eligible' | 'instruction_only_fork_required' + reason: null | string + qualification: string + qualification_sequence: number | null + notice_variant: 'first' | 'returning' | null + organization_name: string | null + contribution_state: 'new' | 'prepared' + professionalism_check?: null | WisdomReviewCheck +} + +export interface WisdomCandidateEvent { + id: string + kind: 'wisdom.candidate' + session_id: null | string + task_id: null | string + content_hash: string + qualification_sequence: number + notice_variant: 'first' | 'returning' + organization_name: string | null + payload: { + skill_name: string + editorial_name?: string + editorial_description?: string + qualification: string + local_reasons: Record + consent_required: true + networked: false + } +} + +export interface WisdomSkillSummary { + id: string + slug: string + state: string + latest_version: null | number + author_description: null | string + install_count: number + scan_verdict?: null | string + system_spec?: null | Record + security_check?: null | WisdomReviewCheck + professionalism_check?: null | WisdomReviewCheck +} + +export interface WisdomDiscovery { + skills: WisdomSkillSummary[] + next_cursor: null | string +} + +export interface WisdomDraft { + id: string + slug: string + state: string + authorDescription: null | string + explanation?: null | string + scan?: null | Record + scanVerdict: null | string + systemSpec?: null | Record + updatedAt: string + security_check?: null | WisdomReviewCheck + professionalism_check?: null | WisdomReviewCheck +} + +export interface WisdomPreparedDraft { + hashes: WisdomDraftReview['hashes'] + network_submission: false + local_draft_id: string + overlay_path: string + drafted_description: string + files: WisdomDraftReview['files'] + local_scan: WisdomLocalScan + system_specification: Record + next_step: string + professionalism_check: WisdomReviewCheck +} + +export type WisdomCandidatePreparation = + | { + stage: 'prepared' + prepared: WisdomPreparedDraft + local_skill_id: string + skill_name: string + } + | { stage: 'review'; review: WisdomDraftReview } + +export interface WisdomLocalScan { + guard: Record + skill_evaluator: Record +} + +export interface WisdomSubmittedDraft { + draft: WisdomDraft + local_scan: WisdomLocalScan + notice: string + professionalism_check: WisdomReviewCheck +} + +export interface WisdomDraftReview { + draft: WisdomDraft & Record + effective_policy: Record + files: Array<{ content_utf8: string; hash: string; mode: 'exec' | 'file'; path: string }> + hashes: { author_description: string; content: string; package_manifest: string } + receipt: null | string +} + +export type WisdomPublicationReview = WisdomDraftReview & { + publication_mode: 'open' | 'managed' | 'moderated' + portal_url?: string +} +export interface WisdomPublicationResult { + draft_id: string + publication_state: 'pending_moderation' | 'published' + portal_url: string +} + +export const reviewWisdomPublication = (draftId: string, profile?: ProfileScope): Promise => + request('/api/wisdom/publication/review', profile, { + method: 'POST', + timeoutMs: 120_000, + body: { draft_id: draftId } + }) + +export const submitWisdomPublication = ( + review: WisdomPublicationReview, + profile?: ProfileScope, + consent?: { interaction_id: string; session_id: string } +): Promise => + request('/api/wisdom/publication/submit', profile, { + method: 'POST', + timeoutMs: 120_000, + body: { + draft_id: review.draft.id, + expected_hashes: review.hashes, + publication_mode: review.publication_mode, + ...consent + } + }) + +export const prepareWisdomConsentPublication = ( + interactionId: string, + sessionId: string, + profile?: ProfileScope +): Promise<{ draft_id: string }> => + request('/api/wisdom/consent/publication-review', profile, { + method: 'POST', + timeoutMs: 120_000, + body: { interaction_id: interactionId, session_id: sessionId, action: 'inspect' } + }) + +export interface WisdomEditedFile { + path: string + content_utf8: string +} + +export interface WisdomRevisedDraft { + draft: WisdomDraft & Record + local_scan: WisdomLocalScan + notice: string + professionalism_check: WisdomReviewCheck +} + +export interface WisdomSkillDetail { + latest_version_detail?: Record + local_compatibility?: Record + local_installation?: null | Record + portal_url?: string | null + skill: Record + versions: Array> +} + +export interface WisdomVersionDetail { + local_compatibility?: Record + local_installation?: null | Record + portal_url?: string | null + skill: Record + version: Record +} + +export interface WisdomVersionContent { + commit: string + content_hash: string + files: Array<{ content_utf8: string; hash: string; mode: 'exec' | 'file'; path: string }> +} + +export type WisdomUpdateMode = 'AUTO_WITH_NOTICE' | 'MANUAL' | 'REQUIRED' + +export interface WisdomManagedInstall { + skill_id: string + slug: string + version: number + update_mode: WisdomUpdateMode + state: string + target_path: string +} + +export type WisdomNotificationCategory = + 'installed' | 'new_skill' | 'publication_decision' | 'unavailable' | 'update_available' | 'updated' + +export interface WisdomNotification { + category: WisdomNotificationCategory + editorial_description?: string | null + editorial_name?: string | null + event_id: string + kind: string + moderation_note?: string | null + occurred_at?: string | null + portal_url?: string | null + skill_id: string + skill_name: string + source_event_ids: string[] + state?: string | null + version?: number | null +} + +export interface WisdomInstallations { + delivery_mode?: 'agent' | 'fixed' + installations: WisdomManagedInstall[] + notifications: WisdomNotification[] +} + +export type WisdomConsentAction = + 'inspect' | `inspect.${number}` | 'defer' | 'confirm' | 'recheck' | 'setup.status' | 'setup.recover' | 'setup.clear' + +export interface WisdomConsentInteraction { + id: string + assessment_id: string + state: string + operation: 'share' | 'install' | 'update' | 'publish' | 'setup' + expires_at: number + actions: ('defer' | 'inspect' | 'confirm')[] + deferred?: boolean + deferred_surfaces?: string[] + inspection?: { path: string; content: string; hash: string; description: string; page: number; page_count: number } + result?: { packaging_state?: 'queued' | 'ready' | 'failed' } + setup_review?: { + summary: string + detail: string + command: string + command_label: string + actions: { action: WisdomConsentAction; label: string; primary: boolean }[] + } + facts: { + step?: { phase: 'prerequisite' | 'setup' | 'verify'; index: number; command: string } + setup_instruction?: string + setup_explanation?: string + slug?: string + version?: number + editorial_name?: string | null + editorial_description?: string | null + compatibility?: { outcome: string } + modified?: boolean + sensitive_expansion?: string[] + security_check?: WisdomReviewCheck | null + professionalism_check?: WisdomReviewCheck | null + file_names?: string[] + } +} + +export interface WisdomMediationActivity { + mode: 'fixed' | 'agent' + assessments: { + id: string + state: string + owner_session: string | null + advice: null | { title: string; explanation: string; relevance: 'recommend' | 'digest' } + }[] + interactions: WisdomConsentInteraction[] +} + +export type WisdomInstallationCheckState = + 'archived' | 'current' | 'not_recorded' | 'taken_down' | 'update_available' | 'updated' + +export interface WisdomInstallationCheck { + skill_id: string + state: WisdomInstallationCheckState + plan?: WisdomActionPlan + result?: Record +} + +export interface WisdomCheckResult { + installations: WisdomInstallationCheck[] + qualification_events?: unknown[] + feed?: Record + owner_decisions?: Record + telegram?: Record +} + +export interface WisdomActionPlan { + receipt?: string + state?: string + skill_id: string + version?: number + compatibility?: { outcome: string; reasons?: string[] } + sensitive_expansion?: string[] + modified?: boolean + update_mode?: string + allowed?: boolean +} + +const request = ( + path: string, + profile?: ProfileScope, + init?: { body?: unknown; method?: string; timeoutMs?: number } +): Promise => + window.hermesDesktop.api({ + ...capabilityScoped(profile), + path, + method: init?.method, + timeoutMs: init?.timeoutMs, + body: init?.body + }) + +export const getWisdomStatus = (profile?: ProfileScope): Promise => request('/api/wisdom/status', profile) +export const getWisdomEntitlement = (profile?: ProfileScope): Promise => + request('/api/wisdom/entitlement', profile) + + +export const getWisdomSync = (profile?: ProfileScope): Promise => + request('/api/wisdom/sync', profile) +export const retryWisdomSync = (profile?: ProfileScope): Promise => + request('/api/wisdom/sync/retry', profile, { method: 'POST', body: {}, timeoutMs: 120_000 }) + +export const getWisdomMute = (profile?: ProfileScope): Promise => + request('/api/wisdom/mute', profile) +export const prepareWisdomMute = (profile?: ProfileScope): Promise => + request('/api/wisdom/mute/prepare', profile, { method: 'POST', body: {} }) +export const chooseWisdomMute = ( + controlId: string, + duration: WisdomMuteDuration, + profile?: ProfileScope +): Promise => + request('/api/wisdom/mute/choose', profile, { method: 'POST', body: { control_id: controlId, duration } }) + +export const getWisdomMediation = (profile?: ProfileScope): Promise => + request('/api/wisdom/mediation', profile) + +export const resolveWisdomConsent = ( + interactionId: string, + sessionId: string, + action: WisdomConsentAction, + profile?: ProfileScope +): Promise => + request('/api/wisdom/consent', profile, { + method: 'POST', + body: { interaction_id: interactionId, session_id: sessionId, action } + }) + +export const setupWisdom = (profile?: ProfileScope): Promise => + request('/api/wisdom/setup', profile, { method: 'POST', body: { accept_disclosure: true } }) + +export const scanWisdom = (skill?: string, profile?: ProfileScope): Promise => + request('/api/wisdom/scan', profile, { method: 'POST', body: { skill } }) + +export const getWisdomCandidates = (profile?: ProfileScope): Promise<{ candidates: WisdomCandidate[] }> => + request('/api/wisdom/candidates', profile) + +export const getWisdomEvents = ( + sessionId: string, + profile?: ProfileScope +): Promise<{ events: WisdomCandidateEvent[] }> => + request(`/api/wisdom/events?session_id=${encodeURIComponent(sessionId)}`, profile) + +export const getWisdomDiscovery = (profile?: ProfileScope): Promise => + request('/api/wisdom/discovery', profile) + +export const getWisdomDrafts = (profile?: ProfileScope): Promise<{ drafts: WisdomDraft[] }> => + request('/api/wisdom/drafts', profile) + +export const getWisdomSkill = (skillId: string, profile?: ProfileScope): Promise => + request(`/api/wisdom/skills/${encodeURIComponent(skillId)}`, profile) + +export const getWisdomVersion = ( + skillId: string, + version: number, + profile?: ProfileScope +): Promise => + request(`/api/wisdom/skills/${encodeURIComponent(skillId)}/versions/${version}`, profile) + +export const getWisdomVersionContent = ( + skillId: string, + version: number, + profile?: ProfileScope +): Promise => + request(`/api/wisdom/skills/${encodeURIComponent(skillId)}/versions/${version}/content`, profile) + +export const getWisdomInstallations = (profile?: ProfileScope): Promise => + request('/api/wisdom/installations', profile) + +export const checkWisdom = (profile?: ProfileScope, applyAutomatic = true): Promise => + request('/api/wisdom/check', profile, { method: 'POST', body: { apply_automatic: applyAutomatic } }) + +export const planWisdomInstall = ( + reference: string, + profile?: ProfileScope, + updateMode?: WisdomUpdateMode +): Promise => + request('/api/wisdom/install/plan', profile, { + method: 'POST', + body: { reference, update_mode: updateMode } + }) + +export const applyWisdomInstall = ( + receipt: string, + acceptPartial: boolean, + profile?: ProfileScope +): Promise> => + request('/api/wisdom/install/apply', profile, { + method: 'POST', + body: { accept_partial: acceptPartial, receipt } + }) + +export const planWisdomUpdate = (skillId: string, profile?: ProfileScope): Promise => + request('/api/wisdom/update/plan', profile, { method: 'POST', body: { skill_id: skillId } }) + +export const applyWisdomUpdate = ( + receipt: string, + confirmations: { acceptPartial: boolean; acceptSensitive: boolean; preserveModified: boolean }, + profile?: ProfileScope +): Promise> => + request('/api/wisdom/update/apply', profile, { + method: 'POST', + body: { + accept_partial: confirmations.acceptPartial, + accept_sensitive: confirmations.acceptSensitive, + preserve_modified: confirmations.preserveModified, + receipt + } + }) + +export const uninstallWisdomSkill = (skillId: string, profile?: ProfileScope): Promise> => + request('/api/wisdom/uninstall', profile, { method: 'POST', body: { skill_id: skillId } }) + +export const acknowledgeWisdomNotifications = (profile?: ProfileScope): Promise<{ events: WisdomNotification[] }> => + request('/api/wisdom/notifications', profile, { method: 'POST', body: { mark_seen: true } }) + +export const suggestWisdomSkill = ( + skill: string, + profile?: ProfileScope, + approval?: { description: string; systemSpecification: Record }, + localSkillId?: string +): Promise => + request('/api/wisdom/suggest', profile, { + method: 'POST', + body: { + skill, + local_skill_id: localSkillId, + description: approval?.description, + system_specification: approval?.systemSpecification + } + }) + +export const reviewWisdomDraft = ( + draftId: string, + acknowledge: boolean, + profile?: ProfileScope +): Promise => + request('/api/wisdom/review', profile, { + method: 'POST', + body: { acknowledge, draft_id: draftId } + }) + +export const saveWisdomPreparedDraft = ( + draftId: string, + authorDescription: string, + files: WisdomEditedFile[], + profile?: ProfileScope +): Promise => + request('/api/wisdom/prepared/save', profile, { + method: 'POST', + body: { author_description: authorDescription, draft_id: draftId, files } + }) + +export const dismissWisdomCandidate = ( + localSkillId: string, + contentHash: string, + profile?: ProfileScope +): Promise<{ dismissed: true }> => + request('/api/wisdom/candidates/dismiss', profile, { + method: 'POST', + body: { content_hash: contentHash, local_skill_id: localSkillId } + }) + +export const deferWisdomCandidate = ( + eventId: string, + profile?: ProfileScope +): Promise<{ event_id: string; state: 'deferred' }> => + request('/api/wisdom/candidates/defer', profile, { + method: 'POST', + body: { event_id: eventId } + }) + +export const prepareWisdomCandidate = (eventId: string, profile?: ProfileScope): Promise => + request('/api/wisdom/candidates/prepare', profile, { + method: 'POST', + body: { event_id: eventId } + }) + +export const approveWisdomCandidate = (eventId: string, profile?: ProfileScope): Promise> => + request('/api/wisdom/candidates/approve', profile, { + method: 'POST', + body: { event_id: eventId } + }) + +export const reviseWisdomDraft = ( + draftId: string, + authorDescription: string, + files: WisdomEditedFile[], + hashes: WisdomDraftReview['hashes'], + profile?: ProfileScope +): Promise => + request('/api/wisdom/revise', profile, { + method: 'POST', + body: { + draft_id: draftId, + author_description: authorDescription, + files, + expected_content_hash: hashes.content, + expected_author_description_hash: hashes.author_description, + expected_package_manifest_hash: hashes.package_manifest + } + }) + +export const decideWisdomDraft = ( + draftId: string, + decision: 'approve' | 'decline', + profile?: ProfileScope +): Promise> => + request(`/api/wisdom/${decision}`, profile, { method: 'POST', body: { draft_id: draftId } }) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx index 518090d96c..1fa915b9d9 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.test.tsx @@ -157,6 +157,32 @@ describe('useSlashCompletions', () => { expect(commandsOf(await completions(api, 'research'))).toEqual(['/research-paper-writing', '/research']) }) + it('shows backend documentation for wisdom subcommands', async () => { + const request = vi.fn().mockImplementation((method: string) => + Promise.resolve( + method === 'commands.catalog' + ? CATALOG + : { + replace_from: 8, + items: [ + { + text: 'installed', + display: 'installed', + meta: 'List and manage skills installed on this device' + } + ] + } + ) + ) + + const api = harness({ request } as unknown as HermesGateway) + const [installed] = await completions(api, 'wisdom ') + + expect(installed?.label).toBe('installed') + expect(installed?.description).toBe('List and manage skills installed on this device') + expect((installed?.metadata as { command?: string })?.command).toBe('/wisdom installed') + }) + it('keeps a registry command in Commands even when the desktop table has no row', async () => { const request = vi.fn().mockImplementation((method: string) => Promise.resolve( diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 1b95bc769a..c2786691aa 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -18,7 +18,7 @@ import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { ErrorState } from '@/components/ui/error-state' import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger' -import { type HermesGateway } from '@/hermes' +import { type HermesGateway, type ProfileScope } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' import { NEW_SESSION_TITLE, quickModelOptions, sessionTitle } from '@/lib/chat-runtime' @@ -28,6 +28,7 @@ import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { migrateSessionDraft } from '@/store/composer' import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue' +import { activeGatewayConnectionId } from '@/store/gateway' import { $introSplash } from '@/store/intro-splash' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' @@ -428,6 +429,13 @@ const ChatViewContent = memo(function ChatViewContent({ const awaitingResponse = useStore(view.$awaitingResponse) const busy = useStore(view.$busy) const activeGatewayProfile = useStore($activeGatewayProfile) + const wisdomConnectionId = activeGatewayConnectionId() + + const wisdomProfile = useMemo( + () => ({ connectionId: wisdomConnectionId, profile: activeGatewayProfile }), + [activeGatewayProfile, wisdomConnectionId] + ) + const contextSuggestions = useStore($contextSuggestions) // Per-session (SessionView) reads — a tile IS its session, so these come // from the view slice, not the global atoms (which track the primary only). @@ -697,6 +705,7 @@ const ChatViewContent = memo(function ChatViewContent({ onRestoreToMessage={onRestoreToMessage} sessionId={activeSessionId} sessionKey={threadKey} + wisdomProfile={wisdomProfile} /> {resumeExhausted && routedSessionId && (
diff --git a/apps/desktop/src/app/skills/collective-tab.test.tsx b/apps/desktop/src/app/skills/collective-tab.test.tsx new file mode 100644 index 0000000000..53f021c33c --- /dev/null +++ b/apps/desktop/src/app/skills/collective-tab.test.tsx @@ -0,0 +1,610 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as HermesApi from '@/hermes' + +const getWisdomStatus = vi.fn() +const getWisdomDiscovery = vi.fn() +const getWisdomCandidates = vi.fn() +const getWisdomDrafts = vi.fn() +const getWisdomSkill = vi.fn() +const getWisdomInstallations = vi.fn() +const getWisdomVersionContent = vi.fn() +const suggestWisdomSkill = vi.fn() +const reviewWisdomDraft = vi.fn() +const reviewWisdomPublication = vi.fn() +const saveWisdomPreparedDraft = vi.fn() +const submitWisdomPublication = vi.fn() +const reviseWisdomDraft = vi.fn() +const decideWisdomDraft = vi.fn() +const planWisdomInstall = vi.fn() +const applyWisdomInstall = vi.fn() +const planWisdomUpdate = vi.fn() +const applyWisdomUpdate = vi.fn() +const checkWisdom = vi.fn() +const setupWisdom = vi.fn() +const getActionStatus = vi.fn() + +vi.mock('@/hermes', async importOriginal => ({ + ...(await importOriginal()), + decideWisdomDraft, + getWisdomCandidates, + getWisdomDiscovery, + getWisdomDrafts, + getWisdomSkill, + getWisdomInstallations, + getWisdomVersionContent, + getWisdomStatus, + reviewWisdomDraft, + reviewWisdomPublication, + saveWisdomPreparedDraft, + submitWisdomPublication, + reviseWisdomDraft, + suggestWisdomSkill, + planWisdomInstall, + applyWisdomInstall, + planWisdomUpdate, + applyWisdomUpdate, + checkWisdom, + setupWisdom, + getActionStatus +})) + +vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) + +const scope = { connectionId: 'gateway-a', profile: 'research' } +const originalScrollIntoView = Element.prototype.scrollIntoView + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() +}) + +afterAll(() => { + Element.prototype.scrollIntoView = originalScrollIntoView +}) + +beforeEach(() => { + checkWisdom.mockResolvedValue({ installations: [] }) +}) + +const systemSpecification = { + hermes: { minimum_version: '0.20.5' }, + platforms: ['macOS'], + architectures: ['arm64'], + model: { capabilities: [], minimum_context_window: null }, + tools: [], + plugins: [], + credentials: [], + connections: [], + filesystem: { read: [], write: [] }, + network: { destinations: [] }, + runtime: { shell: false, browser: false, code: false, sandbox: true }, + hardware: [], + known_limitations: [] +} + +async function renderTab(initialEntry = '/skills?tab=collective') { + const { CollectiveTab } = await import('./collective-tab') + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return render( + + + + + + ) +} + +afterEach(() => vi.clearAllMocks()) + +function mockInstallations() { + getWisdomInstallations.mockResolvedValue({ installations: [], notifications: [] }) +} + +describe('CollectiveTab', () => { + it('keeps collective reads disabled until disclosure setup is accepted', async () => { + getWisdomStatus + .mockResolvedValueOnce({ configured: false, verified_org_id: null }) + .mockResolvedValueOnce({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + mockInstallations() + setupWisdom.mockResolvedValue({ ok: true, name: 'wisdom-setup', pid: 1 }) + getActionStatus.mockResolvedValue({ + name: 'wisdom-setup', + running: false, + exit_code: 0, + pid: 1, + lines: [] + }) + + await renderTab() + expect(await screen.findByText(/Candidate qualification stays on this profile/)).toBeTruthy() + expect(getWisdomDiscovery).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /set up this profile/ })) + + await waitFor(() => expect(setupWisdom).toHaveBeenCalledWith(scope)) + expect(getActionStatus).toHaveBeenCalledWith('wisdom-setup', 80, scope) + }, 30_000) + + it('scopes reads to the selected connection/profile and renders hostile text as text', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: '', + author_description: '', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + + expect(await screen.findByText('')).toBeTruthy() + expect(globalThis.document.querySelector('script')).toBeNull() + expect(getWisdomDiscovery).toHaveBeenCalledWith(scope) + expect(getWisdomCandidates).toHaveBeenCalledWith(scope) + }) + + it('refreshes shared-skill discovery on demand', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery + .mockResolvedValueOnce({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'existing-skill', + author_description: 'Already visible', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + .mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'existing-skill', + author_description: 'Already visible', + install_count: 0, + latest_version: 1, + state: 'active' + }, + { + id: 'skill-2', + slug: 'newly-shared-skill', + author_description: 'Published after the screen opened', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + expect(await screen.findByText('existing-skill')).toBeTruthy() + expect(screen.queryByText('newly-shared-skill')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Refresh shared skills' })) + + expect(await screen.findByText('newly-shared-skill')).toBeTruthy() + expect(getWisdomDiscovery).toHaveBeenCalledTimes(2) + }) + + it('refreshes registry discovery while checking managed updates', async () => { + mockInstallations() + checkWisdom.mockResolvedValue({ installations: [] }) + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValueOnce({ next_cursor: null, skills: [] }).mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-2', + slug: 'discovered-during-update-check', + author_description: 'Newly shared', + install_count: 0, + latest_version: 1, + state: 'active' + } + ] + }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: 'Check updates' })) + + expect(await screen.findByText('discovered-during-update-check')).toBeTruthy() + expect(checkWisdom).toHaveBeenCalledWith(scope) + expect(getWisdomDiscovery).toHaveBeenCalledTimes(2) + }) + + it('checks on load and marks installed skills with a pending target version', async () => { + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'gateway-pull-canary', + author_description: 'Managed canary', + install_count: 1, + latest_version: 2, + state: 'active' + } + ] + }) + getWisdomInstallations.mockResolvedValue({ + installations: [ + { + skill_id: 'skill-1', + slug: 'gateway-pull-canary', + version: 1, + update_mode: 'MANUAL', + state: 'active', + target_path: '/managed/gateway-pull-canary' + } + ], + notifications: [] + }) + checkWisdom.mockResolvedValue({ + installations: [ + { + skill_id: 'skill-1', + state: 'update_available', + plan: { skill_id: 'skill-1', version: 2, receipt: 'wup_1' } + } + ] + }) + getWisdomSkill.mockResolvedValue({ + skill: { id: 'skill-1', slug: 'gateway-pull-canary' }, + versions: [{ version: 2 }] + }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + + await renderTab() + + await waitFor(() => expect(checkWisdom).toHaveBeenCalledWith(scope)) + expect(await screen.findByText('v2 update available')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Check updates (1)' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: /gateway-pull-canary/ })) + expect(await screen.findByRole('button', { name: 'Review update' })).toBeTruthy() + }) + + it.each(['open', 'moderated'] as const)('reviews and rescans locally before one final %s submission', async publicationMode => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ + candidates: [ + { + local_skill_id: 'local-1', + name: 'candidate-skill', + eligibility: 'eligible', + reason: null, + qualification: 'manual_selection', + contribution_state: 'new' + } + ] + }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + suggestWisdomSkill.mockResolvedValueOnce({ + network_submission: false, + local_draft_id: 'local:draft', + overlay_path: '/private/overlay', + drafted_description: 'Drafted copy', + system_specification: systemSpecification, + next_step: 'review' + }) + const manifest = JSON.stringify({ schema_version: 1, name: 'candidate-skill', requirements: systemSpecification }) + + const initialReview = { + draft: { id: 'local:draft', slug: 'candidate-skill', state: 'prepared', authorDescription: 'Drafted copy' }, + publication_mode: publicationMode, + effective_policy: {}, + files: [ + { path: 'SKILL.md', mode: 'file', hash: 'sha256:skill', content_utf8: '# Candidate\n' }, + { path: 'skill.manifest.json', mode: 'file', hash: 'sha256:manifest', content_utf8: manifest } + ], + hashes: { content: 'sha256:content', author_description: 'sha256:description', package_manifest: 'sha256:manifest' }, + receipt: null + } + + const rescannedReview = { + ...initialReview, + draft: { ...initialReview.draft, authorDescription: 'Approved owner copy' }, + hashes: { ...initialReview.hashes, author_description: 'sha256:revised' } + } + + reviewWisdomPublication.mockResolvedValueOnce(initialReview).mockResolvedValueOnce(rescannedReview) + saveWisdomPreparedDraft.mockResolvedValue({ local_draft_id: 'local:draft' }) + submitWisdomPublication.mockResolvedValue({ + draft_id: 'draft-1', + publication_state: publicationMode === 'open' ? 'published' : 'pending_moderation', + portal_url: 'https://portal.example/skill/draft-1' + }) + + await renderTab() + fireEvent.click(await screen.findByText('View all local skills (1)')) + fireEvent.click(await screen.findByRole('button', { name: 'Start contribution' })) + const description = await screen.findByLabelText('Owner-authored description') + fireEvent.change(description, { target: { value: 'Approved owner copy' } }) + const action = publicationMode === 'open' ? 'Publish to team' : 'Submit for approval' + expect(screen.getByRole('button', { name: action })).toHaveProperty('disabled', true) + expect(submitWisdomPublication).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' })) + await waitFor(() => expect(screen.getByRole('button', { name: action })).toHaveProperty('disabled', false)) + expect(saveWisdomPreparedDraft).toHaveBeenCalledWith( + 'local:draft', 'Approved owner copy', + initialReview.files.map(({ path, content_utf8 }) => ({ path, content_utf8 })), scope + ) + fireEvent.click(screen.getByRole('button', { name: action })) + await waitFor(() => expect(submitWisdomPublication).toHaveBeenCalledExactlyOnceWith(rescannedReview, scope, undefined)) + expect(suggestWisdomSkill).toHaveBeenCalledTimes(1) + expect(suggestWisdomSkill.mock.calls[0][3]).toBe('local-1') + expect((await screen.findByRole('link', { name: 'View in Portal' })).getAttribute('href')).toBe( + 'https://portal.example/skill/draft-1' + ) + }) + + it('separates qualified suggestions, manual inventory, and submissions waiting on collective approval', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ + candidates: [ + { + local_skill_id: 'qualified-1', + name: 'qualified-skill', + editorial_name: 'Qualified Skill', + editorial_description: 'A useful skill ready for team review.', + eligibility: 'eligible', + reason: null, + qualification: 'high_usage', + contribution_state: 'new' + }, + { + local_skill_id: 'manual-1', + name: 'manual-only-skill', + eligibility: 'eligible', + reason: null, + qualification: 'manual_selection', + contribution_state: 'new' + } + ] + }) + getWisdomDrafts.mockResolvedValue({ + drafts: [ + { id: 'draft-ready', slug: 'needs-review', state: 'ready' }, + { id: 'draft-pending', slug: 'waiting-on-admin', state: 'pending_moderation' }, + { id: 'draft-published', slug: 'already-shared', state: 'published' }, + { id: 'draft-invalid', slug: 'old-revision', state: 'invalidated' } + ] + }) + + await renderTab() + + expect(await screen.findByText('needs-review')).toBeTruthy() + expect(screen.getByText('Ready for your review')).toBeTruthy() + expect(screen.getByText('waiting-on-admin')).toBeTruthy() + expect(screen.getByText('Waiting for collective administrator approval')).toBeTruthy() + expect( + screen.getByText('Drafts awaiting your review and submissions waiting for collective approval.') + ).toBeTruthy() + expect(screen.getByText('1 qualified suggestion')).toBeTruthy() + expect(screen.getByText('Qualified Skill')).toBeTruthy() + expect(screen.getByText('A useful skill ready for team review.')).toBeTruthy() + expect(screen.getAllByRole('button', { name: 'Start contribution' })).toHaveLength(1) + expect(screen.getByText('View all local skills (1)')).toBeTruthy() + expect(screen.queryByText('already-shared')).toBeNull() + expect(screen.queryByText('old-revision')).toBeNull() + }) + + it('edits owner copy and Markdown through a rescanned successor before approval', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ + drafts: [{ id: 'draft-1', slug: 'editable-skill', state: 'ready', authorDescription: 'Original copy' }] + }) + const manifest = `${JSON.stringify({ schema_version: 1, name: 'editable-skill', requirements: systemSpecification })}\n` + + const initialReview = { + draft: { + id: 'draft-1', + slug: 'editable-skill', + state: 'ready', + authorDescription: 'Original copy', + scanVerdict: 'PASS' + }, + effective_policy: {}, + publication_mode: 'moderated', + files: [ + { path: 'SKILL.md', mode: 'file', hash: 'sha256:skill', content_utf8: '# Original\n' }, + { path: 'skill.manifest.json', mode: 'file', hash: 'sha256:manifest', content_utf8: manifest } + ], + hashes: { + content: 'sha256:content', + author_description: 'sha256:description', + package_manifest: 'sha256:manifest' + }, + receipt: null + } + + const revisedReview = { + ...initialReview, + draft: { ...initialReview.draft, id: 'draft-2', authorDescription: 'Revised copy' }, + files: [{ ...initialReview.files[0], content_utf8: '# Revised\n' }, initialReview.files[1]], + hashes: { ...initialReview.hashes, content: 'sha256:revised' } + } + + reviewWisdomPublication.mockResolvedValueOnce(initialReview).mockResolvedValueOnce(revisedReview) + reviseWisdomDraft.mockResolvedValue({ draft: revisedReview.draft, local_scan: {}, notice: 'rescanned' }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: /editable-skill.*View details/ })) + fireEvent.change(await screen.findByLabelText('Owner-authored description'), { + target: { value: 'Revised copy' } + }) + fireEvent.change(screen.getByLabelText('Edit SKILL.md'), { target: { value: '# Revised\n' } }) + expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', true) + fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' })) + + await waitFor(() => expect(reviseWisdomDraft).toHaveBeenCalledTimes(1)) + expect(reviseWisdomDraft).toHaveBeenCalledWith( + 'draft-1', + 'Revised copy', + [ + { path: 'SKILL.md', content_utf8: '# Revised\n' }, + { path: 'skill.manifest.json', content_utf8: manifest } + ], + initialReview.hashes, + scope + ) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', false) + ) + }) + + it('requires a verified plan before applying a managed install', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ + next_cursor: null, + skills: [ + { + id: 'skill-1', + slug: 'managed-skill', + author_description: 'Does work', + install_count: 0, + latest_version: 2, + state: 'active' + } + ] + }) + getWisdomSkill.mockResolvedValue({ skill: { id: 'skill-1', slug: 'managed-skill' }, versions: [{ version: 2 }] }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + planWisdomInstall + .mockResolvedValueOnce({ + receipt: 'wip_1', + skill_id: 'skill-1', + version: 2, + compatibility: { outcome: 'compatible' } + }) + .mockResolvedValueOnce({ + receipt: 'wip_auto', + skill_id: 'skill-1', + version: 2, + update_mode: 'AUTO_WITH_NOTICE', + compatibility: { outcome: 'compatible' } + }) + applyWisdomInstall.mockResolvedValue({ installed: true }) + + await renderTab() + fireEvent.click(await screen.findByRole('button', { name: /managed-skill/ })) + fireEvent.click(await screen.findByRole('button', { name: 'Install…' })) + const preview = await screen.findByRole('region', { name: 'Confirm install' }) + const dialog = screen.getByRole('dialog', { name: 'Verified managed action plan' }) + + expect(preview.textContent).toContain('wip_1') + fireEvent.click(within(dialog).getByRole('combobox', { name: 'Future updates' })) + fireEvent.click(await screen.findByRole('option', { name: 'Automatic with notice' })) + await waitFor(() => expect(planWisdomInstall).toHaveBeenLastCalledWith('skill-1', scope, 'AUTO_WITH_NOTICE')) + expect(preview.textContent).toContain('wip_auto') + + const confirm = screen.getByRole('button', { name: 'Confirm install' }) + expect(preview.contains(confirm)).toBe(false) + fireEvent.click(confirm) + await waitFor(() => expect(applyWisdomInstall).toHaveBeenCalledWith('wip_auto', false, scope)) + }) + + it('opens the verified update plan from a notification deep link', async () => { + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomSkill.mockResolvedValue({ + skill: { id: 'skill-1', slug: 'managed-skill' }, + versions: [{ version: 3 }] + }) + getWisdomVersionContent.mockResolvedValue({ commit: 'sha256:commit', content_hash: 'sha256:content', files: [] }) + planWisdomUpdate.mockResolvedValue({ + receipt: 'wup_notification', + skill_id: 'skill-1', + version: 3, + compatibility: { outcome: 'compatible' } + }) + + await renderTab('/skills?tab=collective&wisdomAction=update&wisdomSkillId=skill-1') + + await waitFor(() => expect(planWisdomUpdate).toHaveBeenCalledWith('skill-1', scope)) + expect((await screen.findByRole('region', { name: 'Confirm update' })).textContent).toContain('wup_notification') + expect(applyWisdomUpdate).not.toHaveBeenCalled() + }) + + it('plans a pasted Portal install link before allowing an install', async () => { + const portalLink = + 'http://127.0.0.1:3111/orgs/wisdom-local/wisdom/skills/0a192cc7-486e-426d-a6b4-493119c1c011?version=1' + + mockInstallations() + getWisdomStatus.mockResolvedValue({ configured: true, verified_org_id: 'org-1' }) + getWisdomCandidates.mockResolvedValue({ candidates: [] }) + getWisdomDrafts.mockResolvedValue({ drafts: [] }) + getWisdomDiscovery.mockResolvedValue({ next_cursor: null, skills: [] }) + getWisdomSkill.mockResolvedValue({ + skill: { id: '0a192cc7-486e-426d-a6b4-493119c1c011', slug: 'gateway-pull-canary' }, + versions: [] + }) + planWisdomInstall.mockResolvedValue({ + receipt: 'wip_from_link', + skill_id: '0a192cc7-486e-426d-a6b4-493119c1c011', + version: 1, + update_mode: 'AUTO_WITH_NOTICE', + compatibility: { outcome: 'compatible' } + }) + applyWisdomInstall.mockResolvedValue({ installed: true }) + + await renderTab() + fireEvent.change(await screen.findByLabelText('Install from link or skill ID'), { + target: { value: portalLink } + }) + fireEvent.click(screen.getByRole('combobox', { name: 'Future updates' })) + fireEvent.click(await screen.findByRole('option', { name: 'Automatic with notice' })) + fireEvent.click(screen.getByRole('button', { name: 'Review install' })) + + await waitFor(() => expect(planWisdomInstall).toHaveBeenCalledWith(portalLink, scope, 'AUTO_WITH_NOTICE')) + expect(applyWisdomInstall).not.toHaveBeenCalled() + expect(await screen.findByText(/wip_from_link/)).toBeTruthy() + const dialog = screen.getByRole('dialog', { name: 'Verified managed action plan' }) + expect(within(dialog).getByRole('combobox', { name: 'Future updates' }).textContent).toContain( + 'Automatic with notice' + ) + fireEvent.click(screen.getByRole('button', { name: 'Confirm install' })) + await waitFor(() => expect(applyWisdomInstall).toHaveBeenCalledWith('wip_from_link', false, scope)) + }) +}) diff --git a/apps/desktop/src/app/skills/collective-tab.tsx b/apps/desktop/src/app/skills/collective-tab.tsx new file mode 100644 index 0000000000..b9ace5fd25 --- /dev/null +++ b/apps/desktop/src/app/skills/collective-tab.tsx @@ -0,0 +1,1017 @@ +import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { WisdomCheckBadge, WisdomReviewTables } from '@/components/wisdom-checks' +import { WisdomMediationCard } from '@/components/wisdom-mediation-card' +import { WisdomNotificationSettings } from '@/components/wisdom-notification-settings' +import { WisdomNotificationsCard } from '@/components/wisdom-notifications-card' +import { WisdomPublicationReview } from '@/components/wisdom-publication-review' +import { WisdomSyncStatus } from '@/components/wisdom-sync-status' +import { + acknowledgeWisdomNotifications, + applyWisdomInstall, + applyWisdomUpdate, + checkWisdom, + getActionStatus, + getWisdomCandidates, + getWisdomDiscovery, + getWisdomDrafts, + getWisdomInstallations, + getWisdomSkill, + getWisdomStatus, + getWisdomVersionContent, + planWisdomInstall, + planWisdomUpdate, + type ProfileScope, + profileScopeKey, + scanWisdom, + setupWisdom, + suggestWisdomSkill, + uninstallWisdomSkill, + type WisdomActionPlan, + type WisdomCandidate, + type WisdomCheckResult, + type WisdomReviewCheck, + type WisdomUpdateMode +} from '@/hermes' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' + +import { DetailColumn, ListColumn, ListStrip, MasterDetail } from '../master-detail' + +const TERMINAL_DRAFT_STATES = new Set(['published', 'declined', 'invalidated', 'rejected']) +const UPDATE_CHECK_INTERVAL_MS = 5 * 60 * 1000 + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} +} + +const candidateDisplayName = (candidate: WisdomCandidate): string => candidate.editorial_name?.trim() || candidate.name + +const candidateDisplayDescription = (candidate: WisdomCandidate): string => + candidate.editorial_description?.trim() || '' + +async function waitForWisdomAction(name: string, profile: ProfileScope): Promise { + for (let attempt = 0; attempt < 1200; attempt += 1) { + const status = await getActionStatus(name, 80, profile) + + if (!status.running) { + if (status.exit_code !== 0) { + throw new Error(status.lines.at(-1) || `Collective Wisdom action failed (${status.exit_code ?? 'unknown'})`) + } + + return + } + + await new Promise(resolve => setTimeout(resolve, 500)) + } + + throw new Error('Collective Wisdom action timed out') +} + +export function CollectiveTab({ profile, query }: { profile: ProfileScope; query: string }) { + const { t } = useI18n() + const copy = t.skills.collective + const location = useLocation() + const navigate = useNavigate() + const scope = profileScopeKey(profile) + const [selectedId, setSelectedId] = useState(null) + + const [publicationDraftId, setPublicationDraftId] = useState(null) + const [busy, setBusy] = useState(null) + const [showManualCandidates, setShowManualCandidates] = useState(false) + const [installReference, setInstallReference] = useState('') + const [installUpdateMode, setInstallUpdateMode] = useState<'' | WisdomUpdateMode>('') + + const [actionPlan, setActionPlan] = useState< + null | (WisdomActionPlan & { action: 'install' | 'uninstall' | 'update' }) + >(null) + + const [actionPlanReference, setActionPlanReference] = useState(null) + + const [acceptSensitive, setAcceptSensitive] = useState(false) + const [acceptPartial, setAcceptPartial] = useState(false) + const [preserveModified, setPreserveModified] = useState(false) + + useEffect(() => { + setSelectedId(null) + setPublicationDraftId(null) + setShowManualCandidates(false) + setInstallReference('') + setInstallUpdateMode('') + setActionPlan(null) + setActionPlanReference(null) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + setBusy(null) + }, [scope]) + + const status = useQuery({ + queryKey: ['wisdom-status', scope], + queryFn: () => getWisdomStatus(profile), + staleTime: 30_000 + }) + + const discovery = useQuery({ + queryKey: ['wisdom-discovery', scope], + queryFn: () => getWisdomDiscovery(profile), + staleTime: 30_000, + enabled: status.data?.configured === true + }) + + const candidates = useQuery({ + queryKey: ['wisdom-candidates', scope], + queryFn: () => getWisdomCandidates(profile), + staleTime: 15_000, + enabled: status.data?.configured === true + }) + + const drafts = useQuery({ + queryKey: ['wisdom-drafts', scope], + queryFn: () => getWisdomDrafts(profile), + staleTime: 15_000, + enabled: status.data?.configured === true + }) + + const detail = useQuery({ + queryKey: ['wisdom-detail', scope, selectedId], + queryFn: () => getWisdomSkill(selectedId || '', profile), + enabled: status.data?.configured === true && Boolean(selectedId), + staleTime: 30_000 + }) + + const installations = useQuery({ + queryKey: ['wisdom-installations', scope], + queryFn: () => getWisdomInstallations(profile), + staleTime: 10_000, + enabled: status.data?.configured === true + }) + + const refetchInstallations = installations.refetch + + const updateCheck = useQuery({ + queryKey: ['wisdom-update-check', scope], + queryFn: () => checkWisdom(profile), + enabled: status.data?.configured === true, + staleTime: UPDATE_CHECK_INTERVAL_MS, + refetchInterval: UPDATE_CHECK_INTERVAL_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true + }) + + useEffect(() => { + if (!updateCheck.dataUpdatedAt) { + return + } + + void refetchInstallations().catch(error => notifyError(error, 'Wisdom installation refresh failed')) + }, [refetchInstallations, updateCheck.dataUpdatedAt]) + + const latestSelectedVersion = useMemo( + () => Math.max(0, ...(detail.data?.versions ?? []).map(version => Number(version.version) || 0)), + [detail.data?.versions] + ) + + const content = useQuery({ + queryKey: ['wisdom-content', scope, selectedId, latestSelectedVersion], + queryFn: () => getWisdomVersionContent(selectedId || '', latestSelectedVersion, profile), + enabled: Boolean(selectedId && latestSelectedVersion), + staleTime: 60_000 + }) + + const rows = useMemo(() => { + const needle = query.trim().toLocaleLowerCase() + + return (discovery.data?.skills ?? []).filter( + skill => + !needle || + skill.slug.toLocaleLowerCase().includes(needle) || + (skill.author_description ?? '').toLocaleLowerCase().includes(needle) + ) + }, [discovery.data?.skills, query]) + + const pendingUpdates = useMemo( + () => + new Map( + (updateCheck.data?.installations ?? []) + .filter(item => item.state === 'update_available') + .map(item => [item.skill_id, item] as const) + ), + [updateCheck.data?.installations] + ) + + const activeDrafts = useMemo( + () => (drafts.data?.drafts ?? []).filter(draft => !TERMINAL_DRAFT_STATES.has(draft.state)), + [drafts.data?.drafts] + ) + + const { manualCandidates, qualifiedCandidates } = useMemo(() => { + const all = candidates.data?.candidates ?? [] + + return { + manualCandidates: all.filter(candidate => candidate.qualification === 'manual_selection'), + qualifiedCandidates: all.filter(candidate => candidate.qualification !== 'manual_selection') + } + }, [candidates.data?.candidates]) + + const filterCandidates = useCallback( + (items: WisdomCandidate[]) => { + const needle = query.trim().toLocaleLowerCase() + + return items + .filter(candidate => + !needle + ? true + : [candidate.name, candidateDisplayName(candidate), candidateDisplayDescription(candidate)].some(value => + value.toLocaleLowerCase().includes(needle) + ) + ) + .toSorted((left, right) => candidateDisplayName(left).localeCompare(candidateDisplayName(right))) + }, + [query] + ) + + const visibleQualifiedCandidates = useMemo( + () => filterCandidates(qualifiedCandidates), + [filterCandidates, qualifiedCandidates] + ) + + const visibleManualCandidates = useMemo( + () => filterCandidates(manualCandidates), + [filterCandidates, manualCandidates] + ) + + const refreshContributionData = useCallback(async () => { + await Promise.all([candidates.refetch(), drafts.refetch(), discovery.refetch()]) + }, [candidates, discovery, drafts]) + + const candidateSummary = (candidate: WisdomCandidate): string => { + if (candidate.eligibility !== 'eligible') { + return candidate.reason || copy.localOnly + } + + const qualification = + candidate.qualification === 'manual_selection' + ? copy.localOnly + : candidate.notice_variant === 'first' + ? copy.qualificationFirst(candidate.organization_name) + : copy.qualificationReturning + + return candidate.contribution_state === 'prepared' ? `${qualification} ${copy.savedLocally}` : qualification + } + + const prepare = async (candidate: WisdomCandidate) => { + setBusy(candidate.local_skill_id) + + try { + const result = await suggestWisdomSkill(candidate.name, profile, undefined, candidate.local_skill_id) + setPublicationDraftId('network_submission' in result ? result.local_draft_id : result.draft.id) + } catch (error) { + notifyError(error, 'Collective Wisdom preparation failed') + } finally { + setBusy(null) + } + } + + const openReview = (draftId: string) => setPublicationDraftId(draftId) + + const installed = installations.data?.installations.find( + item => item.skill_id === selectedId && item.state === 'active' + ) + + const selectedUpdate = selectedId ? pendingUpdates.get(selectedId) : undefined + + const planManagedActionForSkill = useCallback( + async (skillId: string, action: 'install' | 'uninstall' | 'update') => { + setSelectedId(skillId) + setBusy(skillId) + + try { + const plan = + action === 'install' + ? await planWisdomInstall(skillId, profile, installUpdateMode || undefined) + : action === 'update' + ? await planWisdomUpdate(skillId, profile) + : { skill_id: skillId, state: 'confirm_uninstall' } + + setActionPlan({ ...plan, action }) + setActionPlanReference(action === 'install' ? skillId : null) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + notifyError(error, `Wisdom ${action} planning failed`) + } finally { + setBusy(null) + } + }, + [installUpdateMode, profile] + ) + + const planManagedAction = async (action: 'install' | 'uninstall' | 'update') => { + if (selectedId) { + await planManagedActionForSkill(selectedId, action) + } + } + + useEffect(() => { + if (!status.data?.configured) { + return + } + + const params = new URLSearchParams(location.search) + const action = params.get('wisdomAction') + const skillId = params.get('wisdomSkillId') + + if ((action !== 'install' && action !== 'update') || !skillId) { + return + } + + params.delete('wisdomAction') + params.delete('wisdomSkillId') + const search = params.toString() + + navigate( + { hash: location.hash, pathname: location.pathname, search: search ? `?${search}` : '' }, + { replace: true } + ) + void planManagedActionForSkill(skillId, action) + }, [location.hash, location.pathname, location.search, navigate, planManagedActionForSkill, status.data?.configured]) + + const planReferencedInstall = async () => { + const reference = installReference.trim() + + if (!reference) { + return + } + + setBusy('install-reference') + + try { + const plan = await planWisdomInstall(reference, profile, installUpdateMode || undefined) + setSelectedId(plan.skill_id) + setActionPlan({ ...plan, action: 'install' }) + setActionPlanReference(reference) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + notifyError(error, 'Wisdom install planning failed') + } finally { + setBusy(null) + } + } + + const replanInstallUpdateMode = async (value: string) => { + if (!actionPlan || actionPlan.action !== 'install') { + return + } + + const previousMode = installUpdateMode + const nextMode = value === 'DEFAULT' ? '' : (value as WisdomUpdateMode) + const reference = actionPlanReference || actionPlan.skill_id + + setInstallUpdateMode(nextMode) + setBusy('install-mode') + + try { + const plan = await planWisdomInstall(reference, profile, nextMode || undefined) + setActionPlan({ ...plan, action: 'install' }) + setAcceptSensitive(false) + setAcceptPartial(false) + setPreserveModified(false) + } catch (error) { + setInstallUpdateMode(previousMode) + notifyError(error, 'Wisdom install planning failed') + } finally { + setBusy(null) + } + } + + const applyManagedAction = async () => { + if (!actionPlan) { + return + } + + setBusy(actionPlan.skill_id) + + try { + if (actionPlan.action === 'uninstall') { + await uninstallWisdomSkill(actionPlan.skill_id, profile) + } else if (!actionPlan.receipt) { + throw new Error('Verified action receipt is missing') + } else if (actionPlan.action === 'install') { + await applyWisdomInstall(actionPlan.receipt, acceptPartial, profile) + } else { + await applyWisdomUpdate(actionPlan.receipt, { acceptPartial, acceptSensitive, preserveModified }, profile) + } + + setActionPlan(null) + setActionPlanReference(null) + + if (actionPlan.action === 'install') { + setInstallReference('') + setInstallUpdateMode('') + } + + await Promise.all([installations.refetch(), discovery.refetch(), detail.refetch(), updateCheck.refetch()]) + } catch (error) { + notifyError(error, `Wisdom ${actionPlan.action} failed`) + } finally { + setBusy(null) + } + } + + const setupProfile = async () => { + setBusy('setup') + + try { + const action = await setupWisdom(profile) + await waitForWisdomAction(action.name, profile) + await status.refetch() + await Promise.all([discovery.refetch(), candidates.refetch(), drafts.refetch(), installations.refetch()]) + } catch (error) { + notifyError(error, 'Collective Wisdom setup failed') + } finally { + setBusy(null) + } + } + + if (status.isPending) { + return
{copy.loading}
+ } + + if (status.isError) { + const error = status.error + + return ( +
+ {copy.unavailable} {error instanceof Error ? error.message : ''} +
+ ) + } + + if (!status.data.configured) { + return ( +
+
+
+

{copy.title}

+

{copy.setup}

+
+

{copy.setupDisclosure}

+ {status.data.error && ( +
+ {status.data.error} +
+ )} + +
+
+ ) + } + + if (discovery.isPending || candidates.isPending || drafts.isPending || installations.isPending) { + return
{copy.loading}
+ } + + if (discovery.isError || candidates.isError || drafts.isError || installations.isError) { + const error = discovery.error || candidates.error || drafts.error || installations.error + + return ( +
+ {copy.unavailable} {error instanceof Error ? error.message : ''} +
+ ) + } + + const statusCopy = status.data?.verified_org_id ? `${status.data.verified_org_id} · ${copy.orgWide}` : copy.setup + + return ( +
+
+ +
+
+
{copy.title}
+
{statusCopy}
+
+
+ + + +
+
+
{ + event.preventDefault() + void planReferencedInstall() + }} + > +
+ + setInstallReference(event.target.value)} + placeholder={copy.installReferencePlaceholder} + size="sm" + value={installReference} + /> +

+ {copy.installReferenceHelp} +

+
+
+ + +

+ {copy.updateModeHelp} +

+
+ +
+ + + { + try { + await acknowledgeWisdomNotifications(profile) + await installations.refetch() + } catch (error) { + notifyError(error, 'Could not acknowledge Wisdom notifications') + } + }} + onPlanAction={(action, event) => planManagedActionForSkill(event.skill_id, action)} + /> +
+ + {copy.sharedSkills(rows.length)}} + right={ + qualifiedCandidates.length > 0 ? ( + + {copy.localCandidates(qualifiedCandidates.length)} + + ) : undefined + } + /> + } + > + {rows.map(skill => ( + + ))} + {rows.length === 0 && ( +
{copy.noShared}
+ )} +
+ +
+ {((candidates.data?.candidates.length ?? 0) > 0 || activeDrafts.length > 0) && ( +
+
+

{copy.potential}

+

{copy.potentialHelp}

+
+ {visibleQualifiedCandidates.map(candidate => ( +
+
+
{candidateDisplayName(candidate)}
+ {candidateDisplayDescription(candidate) && ( +
+ {candidateDisplayDescription(candidate)} +
+ )} +
+ {candidateSummary(candidate)} +
+ {candidate.professionalism_check && ( +
+ +
+ )} +
+ +
+ ))} + {visibleQualifiedCandidates.length === 0 && ( +

{copy.noSuggestions}

+ )} + {manualCandidates.length > 0 && ( +
+ + {showManualCandidates && ( + <> +

+ {copy.browseLocalHelp} +

+
+ {visibleManualCandidates.map(candidate => ( +
+
+
+ {candidateDisplayName(candidate)} +
+ {candidateDisplayDescription(candidate) && ( +
+ {candidateDisplayDescription(candidate)} +
+ )} +
+ {candidateSummary(candidate)} +
+ {candidate.professionalism_check && ( +
+ +
+ )} +
+ +
+ ))} +
+ + )} +
+ )} +
+
+ +
+

{copy.ownerReview}

+

{copy.ownerReviewHelp}

+
+ {activeDrafts.length === 0 ? ( +

{copy.noDrafts}

+ ) : ( + activeDrafts.map(draft => ( + + )) + )} +
+
+
+ )} + + {detail.data && ( +
+

{String(detail.data.skill.slug || detail.data.skill.id)}

+

+ {String(detail.data.skill.authorDescription || detail.data.skill.author_description || '')} +

+ +

{copy.versionHistory}

+
+                  {JSON.stringify(
+                    {
+                      latest_version: detail.data.latest_version_detail,
+                      version_history: detail.data.versions,
+                      local_compatibility: detail.data.local_compatibility
+                    },
+                    null,
+                    2
+                  )}
+                
+ {content.data && ( +
+
content {content.data.content_hash}
+ {content.data.files.map(file => ( +
+ + {file.path} · {file.hash} + +
+                          {file.content_utf8}
+                        
+
+ ))} +
+ )} +
+ {installed ? ( + <> + + {copy.installed(installed.version, installed.update_mode)} + + {selectedUpdate && ( + + {copy.updateAvailable(selectedUpdate.plan?.version)} + + )} + + + + ) : ( + + )} +
+
+ )} +
+
+
+ + {publicationDraftId && ( +
+ { + setPublicationDraftId(null) + void refreshContributionData() + }} + onSubmitted={() => void refreshContributionData()} + profile={profile} + /> +
+ )} + + {actionPlan && ( +
+

{copy.confirmAction(actionPlan.action)}

+
+
{JSON.stringify(actionPlan, null, 2)}
+
+
+ {actionPlan.action === 'install' && ( +
+ + +

+ {copy.updateModeHelp} +

+
+ )} + {actionPlan.state === 'current' &&

{copy.alreadyCurrent}

} + {actionPlan.compatibility && actionPlan.compatibility.outcome !== 'compatible' && ( + + )} + {(actionPlan.sensitive_expansion?.length ?? 0) > 0 && ( + + )} + {actionPlan.modified && actionPlan.update_mode !== 'REQUIRED' && ( + + )} +
+ + {actionPlan.state !== 'current' && ( + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index b4647437dd..6af7ca4805 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -19,6 +19,7 @@ const getUsageAnalytics = vi.fn() const getProfiles = vi.fn() const getSkillContent = vi.fn() const getOfficialSkills = vi.fn() +const getWisdomEntitlement = vi.fn() // Partial mock: keep the real module (SkillsView pulls in @/store/profile, // whose import-time subscription calls setApiRequestProfile) and stub only the @@ -36,7 +37,8 @@ vi.mock('@/hermes', async importOriginal => ({ getUsageAnalytics: (days: number, profile?: null | string) => getUsageAnalytics(days, profile), getProfiles: () => getProfiles(), getSkillContent: (name: string, profile?: null | string) => getSkillContent(name, profile), - getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile) + getOfficialSkills: (profile?: null | string) => getOfficialSkills(profile), + getWisdomEntitlement: (profile?: null | string) => getWisdomEntitlement(profile) })) // Notifications hit nanostores/timers we don't care about here. @@ -45,6 +47,15 @@ vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() })) +// Tab contents have their own suites; this suite owns route-to-panel selection. +vi.mock('@/components/chat/code-editor', () => ({ CodeEditor: () => null })) +vi.mock('./collective-tab', () => ({ + CollectiveTab: () =>
+})) +vi.mock('./plugins-tab', () => ({ + PluginsTab: () =>
+})) + // The catalog Install button routes through the hub action pipeline — stub the // action entrypoint (real module kept: SkillsView reads $hubActions and the // query keys from it). @@ -75,19 +86,22 @@ function toolset(overrides: Record = {}) { } } -async function renderSkills() { +async function renderSkills(tab = 'toolsets') { const { SkillsView } = await import('./index') let result: ReturnType await act(async () => { result = render( // SkillsView reads skills/toolsets via useQuery, so it needs a provider. - + ) }) + if (vi.isFakeTimers()) { + await act(async () => { await vi.advanceTimersByTimeAsync(50) }) + } return result! } @@ -99,6 +113,12 @@ beforeEach(() => { getToolsetConfig.mockResolvedValue({ has_category: true, active_provider: null, providers: [] }) getUsageAnalytics.mockResolvedValue({ tools: [] }) getOfficialSkills.mockResolvedValue({ skills: [] }) + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 + 60 + }) getSkillContent.mockResolvedValue({ name: 'web-research', path: '/skills/web-research/SKILL.md', @@ -111,6 +131,7 @@ beforeEach(() => { afterEach(() => { cleanup() + vi.useRealTimers() vi.clearAllMocks() // Shared singleton client — drop cached skills/toolsets so each test refetches. queryClient.clear() @@ -122,6 +143,117 @@ afterEach(() => { // (2× in a row on PR #93612, plus a main run the same hour). Give this file // headroom; the tests are not slow individually. describe('SkillsView toolset management', { timeout: 60_000 }, () => { + it.each([ + ['collective', 'Collective workspace', 'Plugins workspace'], + ['plugins', 'Plugins workspace', 'Collective workspace'] + ])('opens the %s deep link without replacing the other workspace', async (tab, selected, other) => { + await renderSkills(tab) + + expect(await screen.findByRole('region', { name: selected })).toBeTruthy() + expect(screen.queryByRole('region', { name: other })).toBeNull() + expect(navigateSpy).not.toHaveBeenCalledWith( + { pathname: '/skills', search: '', hash: '' }, + { replace: true } + ) + const otherTab = other.replace(' workspace', '') + fireEvent.click(screen.getByRole('button', { name: otherTab })) + expect(navigateSpy).toHaveBeenCalledWith( + { pathname: '/skills', search: `?tab=${otherTab.toLowerCase()}`, hash: '' }, + { replace: true } + ) + }) + + it('hides Collective Wisdom and redirects its deep link when local entitlement is absent', async () => { + getWisdomEntitlement.mockResolvedValue({ entitled: false, org_id: null, scopes: [], expires_at: null }) + await renderSkills('collective') + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + await waitFor(() => + expect(navigateSpy).toHaveBeenCalledWith( + { pathname: '/skills', search: '', hash: '' }, + { replace: true } + ) + ) + }) + + it('fails closed when an entitlement response is already expired', async () => { + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 - 1 + }) + + await renderSkills('collective') + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + + it('rechecks entitlement after switching away and back while probes are pending', async () => { + const { SkillsView } = await import('./index') + const page = (profile: string) => ( + + + + + + ) + const result = render(page('eligible')) + expect(await screen.findByRole('button', { name: 'Collective' })).toBeTruthy() + + getWisdomEntitlement.mockImplementation(() => new Promise(() => {})) + result.rerender(page('other')) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + result.rerender(page('eligible')) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + }) + + it('hides Collective when its positive JWT reaches expires_at', async () => { + vi.useFakeTimers() + const expiresAt = Date.now() / 1000 + 1 + getWisdomEntitlement.mockResolvedValue({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: expiresAt + }) + + await renderSkills('collective') + expect(screen.queryByRole('region', { name: 'Collective workspace' })).not.toBeNull() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_001) + }) + + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + + it('fails closed when a recheck errors after a positive result', async () => { + vi.useFakeTimers() + getWisdomEntitlement + .mockResolvedValueOnce({ + entitled: true, + org_id: 'org-1', + scopes: ['wisdom:read'], + expires_at: Date.now() / 1000 + 60 + }) + .mockRejectedValue(new Error('probe failed')) + + await renderSkills('collective') + expect(screen.queryByRole('region', { name: 'Collective workspace' })).not.toBeNull() + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000) + }) + + expect(getWisdomEntitlement).toHaveBeenCalledTimes(2) + expect(screen.queryByRole('button', { name: 'Collective' })).toBeNull() + expect(screen.queryByRole('region', { name: 'Collective workspace' })).toBeNull() + }) + it('renders a switch for each toolset and toggles it off', async () => { await renderSkills() @@ -283,6 +415,37 @@ describe('SkillsView toolset management', { timeout: 60_000 }, () => { expect(await screen.findByText(/Deep research steps/)).toBeTruthy() }) + it('uses editorial skill copy while keeping canonical identifiers for actions', async () => { + getSkills.mockResolvedValue([ + { + name: 'web-research', + description: 'Use when researching the web.', + editorial_name: 'Research the Web', + editorial_description: 'Find and compare trustworthy sources online.', + category: 'research', + enabled: true, + usage: 3, + provenance: 'bundled' + } + ]) + + const { SkillsView } = await import('./index') + await act(async () => { + render( + + + + + + ) + }) + + expect((await screen.findAllByText('Research the Web')).length).toBeGreaterThan(0) + expect(screen.getByText('Find and compare trustworthy sources online.')).toBeTruthy() + expect(screen.queryByText('Use when researching the web.')).toBeNull() + expect(getSkillContent).toHaveBeenCalledWith('web-research', 'default') + }) + it('hub picker refuses to reinstall an already-installed skill', async () => { const { notify } = await import('@/store/notifications') const { EmbeddedHubPicker } = await import('./embedded-hub-picker') diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 3a17f97b62..bb27334944 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -21,6 +21,7 @@ import { getSkills, getToolsets, getUsageAnalytics, + getWisdomEntitlement, previewSkillHub, type ProfileScope, profileScopeKey, @@ -67,6 +68,7 @@ import { TerminalBackendPanel } from '../settings/terminal-backend-panel' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' +import { CollectiveTab } from './collective-tab' import { EmbeddedHubPicker } from './embedded-hub-picker' import { McpTab } from './mcp-tab' import { PluginsTab } from './plugins-tab' @@ -75,7 +77,7 @@ import { $skillsSortDesc, $toolsetsSortDesc } from './store' // 'hub' is gone as a top-level tab — the Skills Hub browser lives inside the // Skills tab now (EmbeddedHubPicker below the installed list). Legacy // `?tab=hub` links fall back to 'skills' via useRouteEnumParam. -const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins'] as const +const SKILLS_MODES = ['skills', 'toolsets', 'mcp', 'plugins', 'collective'] as const // Skills + toolsets live in the RQ cache so switching tabs/pages paints the // cached lists instantly (no reload flash) and mount only fires a deduped @@ -119,6 +121,18 @@ const usageOf = (skill: SkillInfo): number => (typeof skill.usage === 'number' ? const categoryFor = (skill: SkillInfo): string => asText(skill.category) || 'general' +type SkillPresentation = { + description: string + editorial_description?: string + editorial_name?: string + name: string +} + +const skillDisplayName = (skill: SkillPresentation): string => asText(skill.editorial_name) || skill.name + +const skillDisplayDescription = (skill: SkillPresentation): string => + asText(skill.editorial_description) || skill.description + // Row subtitle: category, with non-default origins badged. function skillSubtitle(skill: SkillInfo): React.ReactNode { const category = prettyName(categoryFor(skill)) @@ -148,9 +162,14 @@ function filteredSkills(skills: SkillInfo[], query: string, desc: boolean): Skil return skills .filter( skill => - !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || includesQuery(skill.category, q) + !q || + includesQuery(skill.name, q) || + includesQuery(skill.description, q) || + includesQuery(skillDisplayName(skill), q) || + includesQuery(skillDisplayDescription(skill), q) || + includesQuery(skill.category, q) ) - .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || asText(a.name).localeCompare(asText(b.name))) + .sort((a, b) => sign * (usageOf(b) - usageOf(a)) || skillDisplayName(a).localeCompare(skillDisplayName(b))) } // Catalog rows have no usage yet — plain A–Z, same query fields as installed @@ -164,10 +183,12 @@ function filteredOfficial(skills: OfficialSkillInfo[], query: string): OfficialS !q || includesQuery(skill.name, q) || includesQuery(skill.description, q) || + includesQuery(skillDisplayName(skill), q) || + includesQuery(skillDisplayDescription(skill), q) || includesQuery(skill.category, q) || skill.tags.some(tag => includesQuery(tag, q)) ) - .sort((a, b) => asText(a.name).localeCompare(asText(b.name))) + .sort((a, b) => skillDisplayName(a).localeCompare(skillDisplayName(b))) } const toolsetCalls = (toolset: ToolsetInfo, toolCalls: Record): number => @@ -288,6 +309,67 @@ export function SkillsView({ // the wrong machine — withhold it for cross-backend scopes. const crossBackendScope = scopeConnectionId !== null && scopeConnectionId !== (activeGatewayConnectionId() ?? 'local') + // Poll the refresh-free local claim probe so an open page cannot retain a + // positive gate indefinitely. A profile change is fail-closed even if React + // Query has cached that profile from an earlier visit. + const wisdomEntitlement = useQuery({ + queryKey: ['wisdom-entitlement', scopeKey], + queryFn: () => getWisdomEntitlement(scopeProfile), + staleTime: 0, + refetchInterval: 15_000, + retry: false + }) + // Identity changes on every scope visit, including A -> B -> A. + const wisdomScope = useMemo(() => ({ key: scopeKey }), [scopeKey]) + const [acceptedWisdomResult, setAcceptedWisdomResult] = useState(null) + + const [entitlementClock, setEntitlementClock] = useState(() => Date.now()) + + useEffect(() => { + if (wisdomEntitlement.isSuccess && wisdomEntitlement.fetchStatus === 'idle') { + setAcceptedWisdomResult({ scope: wisdomScope, updatedAt: wisdomEntitlement.dataUpdatedAt }) + } + }, [wisdomScope, wisdomEntitlement.dataUpdatedAt, wisdomEntitlement.fetchStatus, wisdomEntitlement.isSuccess]) + + const entitlementExpiresAt = wisdomEntitlement.data?.expires_at + useEffect(() => { + if (typeof entitlementExpiresAt !== 'number') { + return + } + + const remaining = entitlementExpiresAt * 1000 - Date.now() + + if (remaining <= 0) { + setEntitlementClock(Date.now()) + + return + } + + const timeout = window.setTimeout(() => setEntitlementClock(Date.now()), Math.min(remaining, 2_147_483_647)) + + return () => window.clearTimeout(timeout) + }, [entitlementExpiresAt]) + + const freshWisdomEntitlement = + wisdomEntitlement.data?.entitled === true && + typeof entitlementExpiresAt === 'number' && + entitlementExpiresAt * 1000 > entitlementClock + const wisdomEntitled = + acceptedWisdomResult?.scope === wisdomScope && + acceptedWisdomResult.updatedAt === wisdomEntitlement.dataUpdatedAt && + !wisdomEntitlement.isError && + freshWisdomEntitlement + const wisdomDenied = wisdomEntitlement.isError || (wisdomEntitlement.isSuccess && !freshWisdomEntitlement) + + useEffect(() => { + if (mode === 'collective' && wisdomDenied && !wisdomEntitlement.isFetching) { + setMode('skills') + } + }, [mode, setMode, wisdomDenied, wisdomEntitlement.isFetching]) + const { data: profilesData } = useQuery({ queryKey: ['capabilities-profiles'], queryFn: getProfiles, @@ -864,13 +946,20 @@ export function SkillsView({ // searching it is noise. searchHidden={mode === 'mcp' || mode === 'plugins'} searchHints={searchHints} - searchPlaceholder={mode === 'skills' ? t.skills.searchSkills : t.skills.searchToolsets} + searchPlaceholder={ + mode === 'skills' + ? t.skills.searchSkills + : mode === 'collective' && wisdomEntitled + ? t.skills.searchCollective + : t.skills.searchToolsets + } searchValue={query} tabs={[ { id: 'skills', label: t.skills.tabSkills, meta: skills?.length ?? null }, { id: 'toolsets', label: t.skills.tabToolsets, meta: toolsets ? visibleToolsetCount(toolsets) : null }, { id: 'mcp', label: t.skills.tabMcp }, - { id: 'plugins', label: t.skills.tabPlugins } + { id: 'plugins', label: t.skills.tabPlugins }, + ...(wisdomEntitled ? [{ id: 'collective', label: t.skills.tabCollective }] : []) ]} > {/* One shared column: the scope selector sits above whichever tab is @@ -882,8 +971,10 @@ export function SkillsView({ must not sit under a "Configuring: " header. */} {mode !== 'plugins' && profileScopeSelector}
-
- {mode === 'plugins' ? ( +
+ {mode === 'collective' && wisdomEntitled ? ( + + ) : mode === 'plugins' ? ( // Agent plugins for the scoped profile (selector in the section // header), app-level desktop plugins, the live catalog picker // underneath. Keyed on scope so a profile/connection switch @@ -958,8 +1049,8 @@ export function SkillsView({ }} onToggle={enabled => void handleToggleSkill(skill, enabled)} subtitle={skillSubtitle(skill)} - title={skill.name} - toggleLabel={skill.name} + title={skillDisplayName(skill)} + toggleLabel={skillDisplayName(skill)} /> ))} {/* The built-in optional-skills catalog, below the @@ -992,7 +1083,7 @@ export function SkillsView({ key={skill.identifier} onSelect={() => setSelectedOfficial(skill.identifier)} subtitle={prettyName(skill.category)} - title={skill.name} + title={skillDisplayName(skill)} /> ) })} @@ -1214,7 +1305,7 @@ function SkillDetail({ return ( <> {prettyName(categoryFor(skill))} @@ -1225,7 +1316,7 @@ function SkillDetail({ )} } - title={skill.name} + title={skillDisplayName(skill)} /> {editable && (
@@ -1292,14 +1383,14 @@ function OfficialSkillDetail({ return ( <> {prettyName(skill.category)} {t.skills.officialPill} } - title={skill.name} + title={skillDisplayName(skill)} />
+ +
+ )} + {supportsPreview && mode === 'preview' ? ( +
+ +
+ ) : ( +