feat(wisdom): add Hermes Collective Wisdom Agent V1 (#94266)

* feat(wisdom): add trusted publish and install foundation

* feat(wisdom): add private contribution loop

* feat(wisdom): add managed consumption workflows

* fix(wisdom): close cross-repository safety gaps

* fix(wisdom): align local package and lifecycle policy

* fix(wisdom): require explicit profile setup

* docs(wisdom): repin reconciled gateway head

* fix(wisdom): fence content downloads and approval receipts

* docs(wisdom): record generation-fenced downloads

* docs(wisdom): record unified delivery PR

* fix(ci): stop passing invalid classifier inputs

* docs(wisdom): remove internal requirements ledger

* feat(wisdom): localize dashboard and desktop copy

* feat(wisdom): complete local contribution and consumption UX

* style(wisdom): satisfy desktop lint

* chore(wisdom): refresh requirements pin

* test(dashboard): allow formatted profile copy

* test(wisdom): stabilize desktop interaction coverage

* fix(wisdom): surface dashboard action failures

* fix(wisdom): add repeatable Portal demo login

* feat(wisdom): add actionable skill notifications

* feat(wisdom): add notification install and update actions

* fix(wisdom): make Telegram skill alerts actionable

* fix(wisdom): always refresh demo Agent login

* feat(wisdom): embed Telegram notification actions

* fix(wisdom): preserve Telegram notifications after actions

* fix(wisdom): keep Telegram notification cards readable

* feat(wisdom): add Telegram candidate approval flow

* feat(wisdom): explain Telegram qualification reasons

* fix(wisdom): reconcile cross-surface candidate actions

* feat(telegram): add Collective Wisdom management command

* chore(wisdom): refresh Gateway contract pin

* chore(wisdom): advance Gateway contract pin

* feat(wisdom): align command UX across clients

* feat(slack): add Collective Wisdom management parity

* feat(wisdom): add security and professionalism reviews

* feat(wisdom): add first-time qualification guidance

* feat(wisdom): simplify qualification sharing choices

* feat(skills): add optional editorial metadata

* feat(wisdom): enrich legacy skill presentation

* fix(wisdom): harden review and update boundaries

* fix(wisdom): emit canonical review timestamps

* fix(wisdom): align with merged gateway and main

* wisdom: add agent-led sharing core (policy, evidence, schemas, templates, delivery, weekly job, share/install flows)

- hermes_wisdom/agent_led/: policy resolution (server > local > defaults),
  7-day evidence builder that excludes bundled/hub/managed skills and
  dismissed/handled/recently-suggested content hashes, strict pydantic
  schemas for agent output with repair-or-reject, fixed copy templates
  (Share / Teammate / Published / Update / Mute), idempotent retried
  delivery ledger with stale-action resolution, weekly review job,
  resumable Share and Install flows.
- prompts/: candidate review, recipient recommendation, share packaging.
- tests/wisdom/test_agent_led.py: 30 tests.

* wisdom: agent-led renderers and button action dispatcher

- render.py: Telegram HTML, Slack blocks, Desktop payload; editorial name
  is the emphasized line, product label stays separate.
- actions.py: resolve opaque wa:<action>:<dedup> targets via the delivery
  ledger; Not now -> dismissal, Mute -> fixed options, Share -> resumable
  packaging flow, Install/Update -> plan command. Never publishes/installs.

* wisdom: CLI verbs, agent_led config default, conversational catalog skill

- hermes wisdom browse/review-week/act/share/dismiss/mute (all --json).
- wisdom.agent_led config block, default enabled.
- SKILL.md rewritten so natural-language catalog questions map to the CLI
  verbs, share/install flows and fixed notification templates.

* wisdom: wire agent-led weekly review into gateway tick and Telegram buttons

- gateway housekeeping tick calls maybe_run_weekly_review with a home
  channel sender when a Telegram adapter is available.
- Telegram: wa: callbacks resolved through the ledger (stale-safe), mute
  duration keyboard, send_wisdom_agent_recommendation rich card + fallback.

* fix(wisdom): integrate local mediation and harden model and setup boundaries

* fix(wisdom): honor authoritative recommendation policy and defer on failure

* fix(wisdom): synchronize opaque suppression and recheck delivery preferences

* feat(wisdom): route weekly selection through the session-owned assessment queue

* fix(wisdom): prepare and submit the reviewed generated share package

* feat(wisdom): separate native Share preparation from publication consent

* feat(wisdom): sync native mute choices through a leased preference outbox

* feat(wisdom): bind native mute controls to durable preference choices

* feat(wisdom): add scoped desktop and dashboard notification settings

* fix(wisdom): revalidate feed recommendations before assessment and delivery

* fix(wisdom): persist validated delivery receipts before completing notices

* feat(wisdom): add private notification claim and receipt client

* Persist Wisdom send reservations and recover delivery acknowledgements

* Route legacy Wisdom controls through current native review

* Add typed private Wisdom operation outcome client

* fix(wisdom): make agent-led advice usable in the local demo

* fix(wisdom): keep requested consent outside proactive limits

* fix(wisdom): distinguish unavailable assessments and preserve digest text

* fix(wisdom): assess ongoing usefulness beyond the current task

* fix(wisdom): restore immediate qualification sharing controls

* fix(wisdom): separate qualification review from installation advice

* fix(wisdom): collapse review checklists and simplify sharing copy

* fix(wisdom): show compact sharing progress and publication receipts

* fix(wisdom): require credential prefixes rather than matching skill names

* fix(wisdom): finish package checks before presenting sharing consent

* fix(wisdom): scan local skills before qualification cards

* fix(wisdom): update moderation results on existing sharing cards

* fix(wisdom): keep sharing review accessible from receipt cards

* fix(wisdom): align mediated review cards and collapsible checks

* fix(wisdom): clarify clean security summary wording

* fix(wisdom): normalize consent plans and add explicit recheck

* fix(wisdom): keep install and update receipts concise

* fix(wisdom): collapse assessments and deduplicate operation cards

* fix(wisdom): restore private Portal review from native cards

* fix(wisdom): sync Portal publication to original consent card

* fix(wisdom): show local skill version on sharing cards

* fix(wisdom): skip agent recommendations for self-published versions

* fix(wisdom): simplify candidate notices and local-edit recovery copy

* feat(wisdom): submit locally reviewed packages with one confirmation

* feat(wisdom): expose safe receipt and outcome sync recovery

* wisdom: onboarding notice says detect and share, names the user's own skill

Copy review from the product owner on the first and returning
qualification notices (fixed delivery mode):
- the feature blurb now says the org enabled detection *and sharing*
- both notices say the detected skill is one the user created
- both close with an exclamation mark

Applied identically to hermes_wisdom.notice, the desktop and web i18n
strings, and the tests that assert the sentences.

* wisdom: one opener, no approval line, ask to share after the skill is shown

Product owner review of the candidate card.

- The Hermes written card now opens with the same sentence as the fixed card
  ("Your organisation has enabled Collective Wisdom, a feature designed to
  automatically detect and share useful skills across all team members.")
  instead of its own blurb, so there is one first time message.
- "Nothing is shared without your approval." removed from Telegram, Slack
  and Desktop. The buttons already make the permission explicit.
- "Would you like to share?" no longer appears before the skill is named.
  It is now the last line, after the skill name, description, why suggested
  and the checks, and reads "Would you like to share it?" (matching the
  agent led template wording).

Tests updated for the new order; proposalNotice removed from all desktop locales.

* wisdom: American spelling, organization

Product owner decision: user facing copy uses American spelling.
Changes "Your organisation" to "Your organization" in the chat notice,
the Hermes written card opener, the desktop and web strings, and the
tests that assert them. Identifiers such as nas_organisation:* and the
German and French locales are untouched.

* wisdom: candidate card copy round 4 (owner review)

Apply the product owner's round 4 copy decisions to the Hermes Collective
Wisdom candidate card on Telegram, Slack, Desktop and the shared views:

1. Hermes-written cards are titled "Hermes Collective Wisdom" instead of
   the bare "Collective Wisdom".
2. The "Reusable skill ready to review" line is gone from the candidate
   card (Telegram rich card and plain fallback, legacy agent-led share
   template).
3. The skill name and description are labelled: "Skill name: <name>" and
   "What it does: <description>" (Telegram, Slack, Desktop).
4. "Why suggested:" is now "Why others might benefit:".
5. A passing professionalism review reads "Safe to share at work ✓ (no
   inappropriate content found)" with no per-check bullets and no "Pass";
   a failed review reads "Needs a look before sharing at work (possible
   inappropriate content)" and lists only the checks that flagged
   something. Pending/unavailable wording is unchanged.
6. Telegram button toasts: "Will ask later...", "Preparing more
   details...", "Sharing...".
7. Qualification reasons: "You used this skill consistently across many
   days." and "You've really refined this skill."
8. prompts/wisdom_candidate_review.md asks for a compelling
   editorial_name, a simple one_line_description and a compelling
   why_coworkers_benefit under 300 characters; "Be concise and
   convincing." becomes "Be concise and compelling: the goal is that the
   user wants to share it."

Tests updated for the new strings; review_text() gains direct coverage.

* wisdom: re-apply owner copy after rebase

- Native share cards (advice_view/interaction_view): drop the approval line, ask "Would you like to share it?" as the last line after the checks
- Hermes-written completion card titled "Hermes Collective Wisdom"
- Qualification reasons use the owner wording (consistently across many days / really refined)
- American spelling (organization) in remaining English copy
- Desktop test asserts the current Share button; web test matches the returning notice

* fix(wisdom): pin reconciled Gateway and verify Unicode hash vectors

Pin Gateway 60cd2d6b613ae3cd4a6e65155d1142006d907e78 and byte-identical producer artifacts. Verify every content-order case and package-manifest binding. Validation: 186 focused Python tests, Ruff and contract verifier.

* fix(wisdom): reconcile optional SDK tests and frontend lint

* fix(wisdom): default to agent-written notification summaries

* fix(wisdom): restore deferred install review and browse controls

* feat(wisdom): inspect installed setup with exact package provenance

* feat(wisdom): run native-approved installed setup steps with durable evidence

* fix(wisdom): recover interrupted setup with explicit native consent

* feat(wisdom): hand native installs into guided setup review

* fix(wisdom): continue requested setup with fixed notification copy

* fix(wisdom): preserve setup while waiting for a session model

* fix(wisdom): expose canonical setup review controls on desktop

* fix(wisdom): resume setup after recorded automatic updates

* fix(wisdom): make missing setup prerequisites recheckable

* chore(wisdom): align Agent with verified Gateway contract

* fix(wisdom): stop guessing team slugs in portal links

* fix(wisdom): retire pending advice on account sign-out

* fix(wisdom): cancel advice after terminal account revocation

* fix(wisdom): fence feed responses across account sign-out

* fix(wisdom): checkpoint signed-out feed before reactivation

* fix(wisdom): link proactive advice to scoped notification settings

* fix(wisdom): coalesce queued publication recommendations by version

* fix(wisdom): keep package review navigation local and deferable

* fix(wisdom): reflect installed state in discovery controls

* fix(wisdom): show exact checks before command confirmation

* chore(wisdom): pin bounded analytics privacy contract

* chore(wisdom): pin retired legacy notification contract

* feat(wisdom): review publisher usage with exact sharing copy

* fix(wisdom): align discovery and review check summaries

* fix(wisdom): show expired consent before confirmation

* fix(wisdom): require fresh review for legacy install controls

* fix(wisdom): preserve review expiry across check toggles

* fix(wisdom): retain update policy in native install reviews

* fix(wisdom): surface failed native card edits

* fix(wisdom): persist local command approval reviews

* fix(wisdom): use saved approvals for messaging commands

* test(wisdom): provide scan result in setup handoff fixture

* test(wisdom): exercise Telegram approvals with saved review state

* fix(wisdom): retain suppression policy for offline deferral

* fix(wisdom): reconsider candidates after deferred suppression expires

* fix(wisdom): bind review checks and report verified readiness separately

* fix(wisdom): persist accepted publication intent and recover exact outcomes

* fix(sync): pin UTF-8 tree ordering across writers

* chore(wisdom): pin organisation-scoped Gateway authorization

* fix(wisdom): restrict consent delivery to user-facing sessions

* chore(wisdom): refresh reviewed Gateway contract pin

* fix(wisdom): preserve kept tools in Blank Slate exclusions

* test(auth): reset anonymous fixture with a profile-scoped cache

* fix(wisdom): gate local surfaces and work on current profile entitlement

* fix(wisdom): invalidate quiet tool cache on entitlement changes

* test(wisdom): authorize local consent gateway fixtures

* fix(wisdom): keep entitlement decoding free of native crypto imports

* test(wisdom): provide local entitlement to demo CLI subprocess

* ci: leave upstream workflow unchanged in Wisdom PR

* fix(wisdom): ship package and contracts in Nix wheels

---------

Co-authored-by: hbizi <36184542+hbizi@users.noreply.github.com>
This commit is contained in:
shannonsands
2026-09-11 19:04:06 +10:00
committed by GitHub
parent f8456248d9
commit a6ee31f55a
305 changed files with 80674 additions and 1783 deletions
+8
View File
@@ -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.
+6
View File
@@ -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):
+123 -5
View File
@@ -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/<org-id>/``."""
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/<id>/`` (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))
+577
View File
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
scanVerdict: null | string
systemSpec?: null | Record<string, unknown>
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<string, unknown>
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<string, unknown>
skill_evaluator: Record<string, unknown>
}
export interface WisdomSubmittedDraft {
draft: WisdomDraft
local_scan: WisdomLocalScan
notice: string
professionalism_check: WisdomReviewCheck
}
export interface WisdomDraftReview {
draft: WisdomDraft & Record<string, unknown>
effective_policy: Record<string, unknown>
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<WisdomPublicationReview> =>
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<WisdomPublicationResult> =>
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<string, unknown>
local_scan: WisdomLocalScan
notice: string
professionalism_check: WisdomReviewCheck
}
export interface WisdomSkillDetail {
latest_version_detail?: Record<string, unknown>
local_compatibility?: Record<string, unknown>
local_installation?: null | Record<string, unknown>
portal_url?: string | null
skill: Record<string, unknown>
versions: Array<Record<string, unknown>>
}
export interface WisdomVersionDetail {
local_compatibility?: Record<string, unknown>
local_installation?: null | Record<string, unknown>
portal_url?: string | null
skill: Record<string, unknown>
version: Record<string, unknown>
}
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<string, unknown>
}
export interface WisdomCheckResult {
installations: WisdomInstallationCheck[]
qualification_events?: unknown[]
feed?: Record<string, unknown>
owner_decisions?: Record<string, unknown>
telegram?: Record<string, unknown>
}
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 = <T>(
path: string,
profile?: ProfileScope,
init?: { body?: unknown; method?: string; timeoutMs?: number }
): Promise<T> =>
window.hermesDesktop.api<T>({
...capabilityScoped(profile),
path,
method: init?.method,
timeoutMs: init?.timeoutMs,
body: init?.body
})
export const getWisdomStatus = (profile?: ProfileScope): Promise<WisdomStatus> => request('/api/wisdom/status', profile)
export const getWisdomEntitlement = (profile?: ProfileScope): Promise<WisdomEntitlement> =>
request('/api/wisdom/entitlement', profile)
export const getWisdomSync = (profile?: ProfileScope): Promise<WisdomSyncSnapshot> =>
request('/api/wisdom/sync', profile)
export const retryWisdomSync = (profile?: ProfileScope): Promise<WisdomSyncSnapshot> =>
request('/api/wisdom/sync/retry', profile, { method: 'POST', body: {}, timeoutMs: 120_000 })
export const getWisdomMute = (profile?: ProfileScope): Promise<WisdomMuteSnapshot> =>
request('/api/wisdom/mute', profile)
export const prepareWisdomMute = (profile?: ProfileScope): Promise<WisdomMuteControl> =>
request('/api/wisdom/mute/prepare', profile, { method: 'POST', body: {} })
export const chooseWisdomMute = (
controlId: string,
duration: WisdomMuteDuration,
profile?: ProfileScope
): Promise<WisdomMuteSnapshot> =>
request('/api/wisdom/mute/choose', profile, { method: 'POST', body: { control_id: controlId, duration } })
export const getWisdomMediation = (profile?: ProfileScope): Promise<WisdomMediationActivity> =>
request('/api/wisdom/mediation', profile)
export const resolveWisdomConsent = (
interactionId: string,
sessionId: string,
action: WisdomConsentAction,
profile?: ProfileScope
): Promise<WisdomConsentInteraction> =>
request('/api/wisdom/consent', profile, {
method: 'POST',
body: { interaction_id: interactionId, session_id: sessionId, action }
})
export const setupWisdom = (profile?: ProfileScope): Promise<ActionResponse> =>
request('/api/wisdom/setup', profile, { method: 'POST', body: { accept_disclosure: true } })
export const scanWisdom = (skill?: string, profile?: ProfileScope): Promise<ActionResponse> =>
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<WisdomDiscovery> =>
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<WisdomSkillDetail> =>
request(`/api/wisdom/skills/${encodeURIComponent(skillId)}`, profile)
export const getWisdomVersion = (
skillId: string,
version: number,
profile?: ProfileScope
): Promise<WisdomVersionDetail> =>
request(`/api/wisdom/skills/${encodeURIComponent(skillId)}/versions/${version}`, profile)
export const getWisdomVersionContent = (
skillId: string,
version: number,
profile?: ProfileScope
): Promise<WisdomVersionContent> =>
request(`/api/wisdom/skills/${encodeURIComponent(skillId)}/versions/${version}/content`, profile)
export const getWisdomInstallations = (profile?: ProfileScope): Promise<WisdomInstallations> =>
request('/api/wisdom/installations', profile)
export const checkWisdom = (profile?: ProfileScope, applyAutomatic = true): Promise<WisdomCheckResult> =>
request('/api/wisdom/check', profile, { method: 'POST', body: { apply_automatic: applyAutomatic } })
export const planWisdomInstall = (
reference: string,
profile?: ProfileScope,
updateMode?: WisdomUpdateMode
): Promise<WisdomActionPlan> =>
request('/api/wisdom/install/plan', profile, {
method: 'POST',
body: { reference, update_mode: updateMode }
})
export const applyWisdomInstall = (
receipt: string,
acceptPartial: boolean,
profile?: ProfileScope
): Promise<Record<string, unknown>> =>
request('/api/wisdom/install/apply', profile, {
method: 'POST',
body: { accept_partial: acceptPartial, receipt }
})
export const planWisdomUpdate = (skillId: string, profile?: ProfileScope): Promise<WisdomActionPlan> =>
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<Record<string, unknown>> =>
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<Record<string, unknown>> =>
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<string, unknown> },
localSkillId?: string
): Promise<WisdomPreparedDraft | WisdomSubmittedDraft> =>
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<WisdomDraftReview> =>
request('/api/wisdom/review', profile, {
method: 'POST',
body: { acknowledge, draft_id: draftId }
})
export const saveWisdomPreparedDraft = (
draftId: string,
authorDescription: string,
files: WisdomEditedFile[],
profile?: ProfileScope
): Promise<WisdomPreparedDraft> =>
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<WisdomCandidatePreparation> =>
request('/api/wisdom/candidates/prepare', profile, {
method: 'POST',
body: { event_id: eventId }
})
export const approveWisdomCandidate = (eventId: string, profile?: ProfileScope): Promise<Record<string, unknown>> =>
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<WisdomRevisedDraft> =>
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<Record<string, unknown>> =>
request(`/api/wisdom/${decision}`, profile, { method: 'POST', body: { draft_id: draftId } })
@@ -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(
+10 -1
View File
@@ -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<ProfileScope>(
() => ({ 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 && (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
@@ -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<typeof HermesApi>()),
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(
<MemoryRouter initialEntries={[initialEntry]}>
<QueryClientProvider client={client}>
<CollectiveTab profile={scope} query="" />
</QueryClientProvider>
</MemoryRouter>
)
}
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: '<img src=x onerror=alert(1)>',
author_description: '<script>window.pwned=true</script>',
install_count: 0,
latest_version: 1,
state: 'active'
}
]
})
await renderTab()
expect(await screen.findByText('<img src=x onerror=alert(1)>')).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))
})
})
File diff suppressed because it is too large Load Diff
+166 -3
View File
@@ -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: () => <section aria-label="Collective workspace" />
}))
vi.mock('./plugins-tab', () => ({
PluginsTab: () => <section aria-label="Plugins workspace" />
}))
// 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<string, unknown> = {}) {
}
}
async function renderSkills() {
async function renderSkills(tab = 'toolsets') {
const { SkillsView } = await import('./index')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<MemoryRouter initialEntries={[`/skills?tab=${tab}`]}>
<SkillsView />
</MemoryRouter>
</QueryClientProvider>
)
})
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) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<SkillsView fixedProfile={profile} />
</MemoryRouter>
</QueryClientProvider>
)
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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=skills']}>
<SkillsView />
</MemoryRouter>
</QueryClientProvider>
)
})
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')
+106 -15
View File
@@ -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<string, number>): 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 | {
scope: typeof wisdomScope
updatedAt: number
}>(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: <profile>" header. */}
{mode !== 'plugins' && profileScopeSelector}
<div className="flex min-h-0 flex-1 flex-col">
<div className={mode === 'skills' ? 'min-h-40 flex-1 overflow-hidden' : 'min-h-0 flex-1'}>
{mode === 'plugins' ? (
<div className={cn(mode === 'skills' ? 'min-h-40 flex-1 overflow-hidden' : 'min-h-0 flex-1', 'relative')}>
{mode === 'collective' && wisdomEntitled ? (
<CollectiveTab profile={scopeProfile} query={query} />
) : 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 (
<>
<DetailHeader
description={asText(skill.description) || t.skills.noDescription}
description={skillDisplayDescription(skill) || t.skills.noDescription}
pills={
<>
<PanelPill>{prettyName(categoryFor(skill))}</PanelPill>
@@ -1225,7 +1316,7 @@ function SkillDetail({
)}
</>
}
title={skill.name}
title={skillDisplayName(skill)}
/>
{editable && (
<div className="flex items-center gap-2">
@@ -1292,14 +1383,14 @@ function OfficialSkillDetail({
return (
<>
<DetailHeader
description={asText(skill.description) || t.skills.noDescription}
description={skillDisplayDescription(skill) || t.skills.noDescription}
pills={
<>
<PanelPill>{prettyName(skill.category)}</PanelPill>
<PanelPill tone="muted">{t.skills.officialPill}</PanelPill>
</>
}
title={skill.name}
title={skillDisplayName(skill)}
/>
<div className="flex items-center gap-2">
<Button disabled={installing} onClick={onInstall} size="xs" variant="textStrong">
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { MarkdownPreview } from '@/app/chat/right-rail/preview-file'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import type { WisdomDraftReview } from '@/hermes'
import { useI18n } from '@/i18n'
import { WisdomManifestEditor } from './wisdom-manifest-editor'
export function WisdomFileEditor({
file,
value,
disabled = false,
reviewSource = 'server',
onChange
}: {
file: WisdomDraftReview['files'][number]
value: string
disabled?: boolean
reviewSource?: 'local' | 'server'
onChange: (value: string) => void
}) {
const { t } = useI18n()
const copy = t.skills.collective
const [mode, setMode] = useState<'preview' | 'source'>('source')
const reviewLabel = reviewSource === 'local' ? copy.localDraft : copy.serverReviewed
if (file.path === 'skill.manifest.json') {
return (
<details className="border-t border-(--ui-stroke-tertiary) py-3" open>
<summary className="cursor-pointer break-all font-mono text-[0.68rem]">
{file.path} · {reviewLabel} · {file.hash}
</summary>
<WisdomManifestEditor disabled={disabled} onChange={onChange} value={value} />
</details>
)
}
const supportsPreview = file.path.toLocaleLowerCase().endsWith('.md')
return (
<details className="border-t border-(--ui-stroke-tertiary) py-3" open>
<summary className="cursor-pointer break-all font-mono text-[0.68rem]">
{file.path} · {reviewLabel} · {file.hash}
</summary>
{supportsPreview && (
<div aria-label={`${file.path} editor mode`} className="mt-3 flex gap-2">
<Button onClick={() => setMode('source')} size="xs" variant={mode === 'source' ? 'secondary' : 'text'}>
{copy.source}
</Button>
<Button onClick={() => setMode('preview')} size="xs" variant={mode === 'preview' ? 'secondary' : 'text'}>
{copy.preview}
</Button>
</div>
)}
{supportsPreview && mode === 'preview' ? (
<div className="mt-3 max-h-[32rem] min-h-48 overflow-auto bg-(--ui-bg-quaternary)">
<MarkdownPreview text={value} />
</div>
) : (
<Textarea
aria-label={`Edit ${file.path}`}
className="mt-3 max-h-[32rem] min-h-64 w-full resize-y font-mono text-[0.68rem] leading-relaxed"
disabled={disabled}
onChange={event => onChange(event.target.value)}
spellCheck={false}
value={value}
/>
)}
</details>
)
}
@@ -0,0 +1,598 @@
import { useId } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
parseWisdomManifest,
type WisdomManifestV1,
wisdomManifestValidationError,
type WisdomPluginRequirement,
type WisdomSystemSpecification,
type WisdomToolRequirement
} from './wisdom-manifest'
interface SpecificationEditorProps {
value: WisdomSystemSpecification
disabled?: boolean
onChange: (value: WisdomSystemSpecification) => void
}
interface ManifestEditorProps {
value: string
disabled?: boolean
onChange: (value: string) => void
}
const PLATFORM_OPTIONS = [
{ value: 'macOS', label: 'macOS' },
{ value: 'Linux', label: 'Linux' },
{ value: 'Windows', label: 'Windows' }
] as const
const ARCHITECTURE_OPTIONS = [
{ value: 'arm64', label: 'ARM64 / Apple silicon' },
{ value: 'x86_64', label: 'x86-64 / AMD64' }
] as const
function FieldCopy({ label, description }: { label: string; description: string }) {
return (
<div>
<div className="text-[0.68rem] font-medium">{label}</div>
<p className="mt-0.5 text-[0.62rem] leading-4 text-muted-foreground">{description}</p>
</div>
)
}
function StringListField({
label,
description,
itemLabel,
value,
disabled = false,
placeholder,
onChange
}: {
label: string
description: string
itemLabel: string
value: string[]
disabled?: boolean
placeholder?: string
onChange: (value: string[]) => void
}) {
const id = useId()
return (
<div className="space-y-2">
<FieldCopy description={description} label={label} />
{value.map((item, index) => (
<div className="flex items-center gap-2" key={`${id}-${index}`}>
<Input
aria-label={`${itemLabel} ${index + 1}`}
className="min-w-0 flex-1"
disabled={disabled}
maxLength={512}
onChange={event =>
onChange(value.map((existing, itemIndex) => (itemIndex === index ? event.target.value : existing)))
}
placeholder={placeholder}
size="sm"
value={item}
/>
<Button
aria-label={`Remove ${itemLabel} ${index + 1}`}
disabled={disabled}
onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))}
size="xs"
variant="text"
>
Remove
</Button>
</div>
))}
<Button
disabled={disabled || value.length >= 64}
onClick={() => onChange([...value, ''])}
size="xs"
variant="outline"
>
Add {itemLabel}
</Button>
</div>
)
}
function PresetMultiSelect({
label,
description,
value,
options,
disabled,
onChange
}: {
label: string
description: string
value: string[]
options: ReadonlyArray<{ value: string; label: string }>
disabled?: boolean
onChange: (value: string[]) => void
}) {
const id = useId()
const presets = new Set(options.map(option => option.value))
const custom = value.filter(item => !presets.has(item))
return (
<div className="space-y-2">
<FieldCopy description={description} label={label} />
<div className="grid gap-2 sm:grid-cols-2">
{options.map(option => {
const checkboxId = `${id}-${option.value}`
const checked = value.includes(option.value)
return (
<label className="flex cursor-pointer items-center gap-2 text-[0.68rem]" htmlFor={checkboxId} key={option.value}>
<Checkbox
checked={checked}
disabled={disabled}
id={checkboxId}
onCheckedChange={next =>
onChange(next === true ? [...value, option.value] : value.filter(item => item !== option.value))
}
/>
{option.label}
</label>
)
})}
</div>
<StringListField
description={`Add a ${label.toLowerCase().replace(/s$/, '')} not listed above.`}
disabled={disabled}
itemLabel={`other ${label.toLowerCase().replace(/s$/, '')}`}
label={`Other ${label.toLowerCase()}`}
onChange={next => onChange([...value.filter(item => presets.has(item)), ...next])}
value={custom}
/>
</div>
)
}
function BooleanField({
id,
label,
description,
checked,
disabled,
onChange
}: {
id: string
label: string
description: string
checked: boolean
disabled?: boolean
onChange: (checked: boolean) => void
}) {
return (
<label className="flex cursor-pointer items-start gap-2" htmlFor={id}>
<Checkbox
checked={checked}
disabled={disabled}
id={id}
onCheckedChange={next => onChange(next === true)}
/>
<FieldCopy description={description} label={label} />
</label>
)
}
function ToolRequirements({
value,
disabled,
onChange
}: {
value: WisdomToolRequirement[]
disabled?: boolean
onChange: (value: WisdomToolRequirement[]) => void
}) {
const id = useId()
return (
<div className="space-y-3">
<FieldCopy
description="Only tools explicitly needed by this skill belong here. Hermes never copies the full enabled inventory."
label="Tools"
/>
{value.map((tool, index) => (
<div className="grid gap-3 border-t border-(--ui-stroke-tertiary) pt-3 sm:grid-cols-2" key={index}>
<label className="text-[0.65rem]" htmlFor={`${id}-tool-${index}`}>
Tool name
<Input
className="mt-1 w-full"
disabled={disabled}
id={`${id}-tool-${index}`}
maxLength={512}
onChange={event =>
onChange(value.map((item, itemIndex) => (itemIndex === index ? { ...item, name: event.target.value } : item)))
}
size="sm"
value={tool.name}
/>
</label>
<label className="text-[0.65rem]" htmlFor={`${id}-tool-version-${index}`}>
Minimum version (optional)
<Input
className="mt-1 w-full"
disabled={disabled}
id={`${id}-tool-version-${index}`}
maxLength={512}
onChange={event =>
onChange(
value.map((item, itemIndex) =>
itemIndex === index ? { ...item, minimum_version: event.target.value || null } : item
)
)
}
size="sm"
value={tool.minimum_version ?? ''}
/>
</label>
<BooleanField
checked={tool.requires_admin}
description="A teammate must ask an administrator to enable this tool."
disabled={disabled}
id={`${id}-tool-admin-${index}`}
label="Administrator action required"
onChange={requires_admin =>
onChange(value.map((item, itemIndex) => (itemIndex === index ? { ...item, requires_admin } : item)))
}
/>
<div className="flex items-end justify-between gap-3 text-[0.62rem] text-muted-foreground">
<span>Automatic installation is always off.</span>
<Button
disabled={disabled}
onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))}
size="xs"
variant="text"
>
Remove
</Button>
</div>
</div>
))}
<Button
disabled={disabled || value.length >= 64}
onClick={() =>
onChange([...value, { name: '', minimum_version: null, auto_install: false, requires_admin: false }])
}
size="xs"
variant="outline"
>
Add tool
</Button>
</div>
)
}
function PluginRequirements({
value,
disabled,
onChange
}: {
value: WisdomPluginRequirement[]
disabled?: boolean
onChange: (value: WisdomPluginRequirement[]) => void
}) {
const id = useId()
return (
<div className="space-y-3">
<FieldCopy
description="New drafts include only plugins explicitly declared by the skill, not every installed plugin."
label="Plugins"
/>
{value.map((plugin, index) => (
<div className="grid gap-3 border-t border-(--ui-stroke-tertiary) pt-3 sm:grid-cols-2" key={index}>
<label className="text-[0.65rem]" htmlFor={`${id}-plugin-${index}`}>
Plugin ID
<Input
className="mt-1 w-full"
disabled={disabled}
id={`${id}-plugin-${index}`}
maxLength={512}
onChange={event =>
onChange(value.map((item, itemIndex) => (itemIndex === index ? { ...item, id: event.target.value } : item)))
}
size="sm"
value={plugin.id}
/>
</label>
<label className="text-[0.65rem]" htmlFor={`${id}-plugin-version-${index}`}>
Minimum version (optional)
<Input
className="mt-1 w-full"
disabled={disabled}
id={`${id}-plugin-version-${index}`}
maxLength={512}
onChange={event =>
onChange(
value.map((item, itemIndex) =>
itemIndex === index ? { ...item, minimum_version: event.target.value || null } : item
)
)
}
size="sm"
value={plugin.minimum_version ?? ''}
/>
</label>
<BooleanField
checked={plugin.required}
description="Without this plugin, the skill cannot provide its complete behavior."
disabled={disabled}
id={`${id}-plugin-required-${index}`}
label="Required"
onChange={required =>
onChange(value.map((item, itemIndex) => (itemIndex === index ? { ...item, required } : item)))
}
/>
<div className="flex items-end justify-end">
<Button
disabled={disabled}
onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))}
size="xs"
variant="text"
>
Remove
</Button>
</div>
</div>
))}
<Button
disabled={disabled || value.length >= 64}
onClick={() => onChange([...value, { id: '', minimum_version: null, required: true }])}
size="xs"
variant="outline"
>
Add plugin
</Button>
</div>
)
}
export function WisdomSystemSpecificationEditor({
value,
disabled = false,
onChange
}: SpecificationEditorProps) {
const id = useId()
const update = <K extends keyof WisdomSystemSpecification>(key: K, next: WisdomSystemSpecification[K]) =>
onChange({ ...value, [key]: next })
return (
<div className="space-y-5">
<p className="border-l-2 border-(--ui-stroke-secondary) pl-3 text-[0.65rem] leading-4 text-muted-foreground">
Hermes pre-fills new drafts from this device and requirements explicitly recorded on the skill. Selected
platforms and architectures restrict where teammates can install it; clear every selection only when the skill
is known to work everywhere.
</p>
<fieldset className="grid gap-5 border-t border-(--ui-stroke-tertiary) pt-4 sm:grid-cols-2">
<legend className="pr-3 text-[0.68rem] font-medium uppercase tracking-wide">Compatibility targets</legend>
<label className="text-[0.68rem]" htmlFor={`${id}-hermes-version`}>
Minimum Hermes version
<p className="mt-0.5 text-[0.62rem] text-muted-foreground">Older Hermes installations will be blocked.</p>
<Input
className="mt-2 w-full"
disabled={disabled}
id={`${id}-hermes-version`}
maxLength={512}
onChange={event => update('hermes', { minimum_version: event.target.value })}
size="sm"
value={value.hermes.minimum_version}
/>
</label>
<label className="text-[0.68rem]" htmlFor={`${id}-context-window`}>
Minimum model context window
<p className="mt-0.5 text-[0.62rem] text-muted-foreground">Leave blank when no minimum is required.</p>
<Input
className="mt-2 w-full"
disabled={disabled}
id={`${id}-context-window`}
min={1}
onChange={event =>
update('model', {
...value.model,
minimum_context_window: event.target.value === '' ? null : Number(event.target.value)
})
}
size="sm"
step={1}
type="number"
value={value.model.minimum_context_window ?? ''}
/>
</label>
<PresetMultiSelect
description="Checked systems are the allowed install targets."
disabled={disabled}
label="Platforms"
onChange={platforms => update('platforms', platforms)}
options={PLATFORM_OPTIONS}
value={value.platforms}
/>
<PresetMultiSelect
description="Checked processors are the allowed install targets."
disabled={disabled}
label="Architectures"
onChange={architectures => update('architectures', architectures)}
options={ARCHITECTURE_OPTIONS}
value={value.architectures}
/>
<StringListField
description="Capabilities the active model must provide, such as vision."
disabled={disabled}
itemLabel="model capability"
label="Model capabilities"
onChange={capabilities => update('model', { ...value.model, capabilities })}
placeholder="vision"
value={value.model.capabilities}
/>
<StringListField
description="Physical hardware requirements, such as a GPU."
disabled={disabled}
itemLabel="hardware requirement"
label="Hardware"
onChange={hardware => update('hardware', hardware)}
placeholder="gpu"
value={value.hardware}
/>
</fieldset>
<fieldset className="border-t border-(--ui-stroke-tertiary) pt-4">
<legend className="pr-3 text-[0.68rem] font-medium uppercase tracking-wide">Runtime access</legend>
<p className="mb-4 text-[0.62rem] leading-4 text-muted-foreground">
These requirements are checked before installation; they do not grant permissions by themselves.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<BooleanField
checked={value.runtime.shell}
description="The skill needs to run terminal commands."
disabled={disabled}
id={`${id}-shell`}
label="Shell commands"
onChange={shell => update('runtime', { ...value.runtime, shell })}
/>
<BooleanField
checked={value.runtime.browser}
description="The skill needs an interactive browser."
disabled={disabled}
id={`${id}-browser`}
label="Browser control"
onChange={browser => update('runtime', { ...value.runtime, browser })}
/>
<BooleanField
checked={value.runtime.code}
description="The skill needs a code-execution environment."
disabled={disabled}
id={`${id}-code`}
label="Code execution"
onChange={code => update('runtime', { ...value.runtime, code })}
/>
<BooleanField
checked={value.runtime.sandbox}
description="The skill expects an isolated sandbox."
disabled={disabled}
id={`${id}-sandbox`}
label="Sandbox"
onChange={sandbox => update('runtime', { ...value.runtime, sandbox })}
/>
</div>
</fieldset>
<fieldset className="grid gap-5 border-t border-(--ui-stroke-tertiary) pt-4 lg:grid-cols-2">
<legend className="pr-3 text-[0.68rem] font-medium uppercase tracking-wide">Tools and plugins</legend>
<ToolRequirements disabled={disabled} onChange={tools => update('tools', tools)} value={value.tools} />
<PluginRequirements disabled={disabled} onChange={plugins => update('plugins', plugins)} value={value.plugins} />
</fieldset>
<fieldset className="grid gap-5 border-t border-(--ui-stroke-tertiary) pt-4 sm:grid-cols-2">
<legend className="pr-3 text-[0.68rem] font-medium uppercase tracking-wide">Connections and files</legend>
<StringListField
description="Credential names that must already be configured."
disabled={disabled}
itemLabel="credential"
label="Credentials"
onChange={credentials => update('credentials', credentials)}
value={value.credentials}
/>
<StringListField
description="External connections that must already be available."
disabled={disabled}
itemLabel="connection"
label="Connections"
onChange={connections => update('connections', connections)}
value={value.connections}
/>
<StringListField
description="Files or directories the skill must read."
disabled={disabled}
itemLabel="read path"
label="Filesystem read access"
onChange={read => update('filesystem', { ...value.filesystem, read })}
value={value.filesystem.read}
/>
<StringListField
description="Files or directories the skill must write."
disabled={disabled}
itemLabel="write path"
label="Filesystem write access"
onChange={write => update('filesystem', { ...value.filesystem, write })}
value={value.filesystem.write}
/>
<StringListField
description="Network destinations the skill must contact."
disabled={disabled}
itemLabel="network destination"
label="Network destinations"
onChange={destinations => update('network', { destinations })}
value={value.network.destinations}
/>
<StringListField
description="Important constraints teammates should understand before installing."
disabled={disabled}
itemLabel="known limitation"
label="Known limitations"
onChange={known_limitations => update('known_limitations', known_limitations)}
value={value.known_limitations}
/>
</fieldset>
</div>
)
}
export function WisdomManifestEditor({ value, disabled = false, onChange }: ManifestEditorProps) {
let manifest: WisdomManifestV1
try {
manifest = parseWisdomManifest(value)
} catch (reason) {
return (
<div className="mt-3 text-[0.68rem] text-destructive" role="alert">
This manifest cannot be edited as a form: {reason instanceof Error ? reason.message : String(reason)}
</div>
)
}
const update = (next: typeof manifest) => onChange(`${JSON.stringify(next)}\n`)
const validationError = wisdomManifestValidationError(value)
return (
<div className="mt-3 space-y-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<label className="text-[0.68rem]" htmlFor="desktop-wisdom-manifest-name">
Skill package name
<Input
className="mt-1 w-full"
disabled={disabled}
id="desktop-wisdom-manifest-name"
maxLength={512}
onChange={event => update({ ...manifest, name: event.target.value })}
size="sm"
value={manifest.name}
/>
</label>
<div className="self-end pb-1 text-[0.62rem] text-muted-foreground">Schema 1 · fixed by V1</div>
</div>
<WisdomSystemSpecificationEditor
disabled={disabled}
onChange={requirements => update({ ...manifest, requirements })}
value={manifest.requirements}
/>
{validationError && (
<div className="text-[0.68rem] text-destructive" role="alert">
{validationError}
</div>
)}
</div>
)
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
parseWisdomManifest,
parseWisdomSystemSpecification,
wisdomManifestValidationError,
wisdomSystemSpecificationValidationError
} from './wisdom-manifest'
const specification = {
hermes: { minimum_version: '0.20.5' },
platforms: ['macOS'],
architectures: ['arm64'],
model: { capabilities: ['vision'], minimum_context_window: 32_000 },
tools: [{ name: 'browser', minimum_version: null, auto_install: false as const, requires_admin: false }],
plugins: [{ id: 'research-tools', minimum_version: '1.2.0', required: true }],
credentials: ['portal'],
connections: ['gateway'],
filesystem: { read: ['/workspace'], write: ['/tmp'] },
network: { destinations: ['portal.nousresearch.com'] },
runtime: { shell: false, browser: true, code: false, sandbox: true },
hardware: [],
known_limitations: ['Requires an authenticated Portal session.']
}
describe('Desktop Wisdom manifest forms', () => {
it('parses the complete V1 contract without dropping structured fields', () => {
expect(parseWisdomSystemSpecification(specification)).toEqual(specification)
expect(
parseWisdomManifest(JSON.stringify({ schema_version: 1, name: 'research-helper', requirements: specification }))
).toEqual({ schema_version: 1, name: 'research-helper', requirements: specification })
})
it('fails closed on unsupported fields and automatic dependency installation', () => {
expect(() => parseWisdomSystemSpecification({ ...specification, surprise: true })).toThrow(/unsupported fields/)
expect(() =>
parseWisdomSystemSpecification({
...specification,
tools: [{ name: 'browser', minimum_version: null, auto_install: true, requires_admin: false }]
})
).toThrow(/cannot request automatic installation/)
})
it('reports form-level validation errors before a revision is submitted', () => {
expect(wisdomSystemSpecificationValidationError({ ...specification, platforms: [''] })).toMatch(/required/)
expect(
wisdomManifestValidationError(
JSON.stringify({ schema_version: 1, name: '', requirements: specification })
)
).toBe('Skill name is required.')
})
})
@@ -0,0 +1,255 @@
export interface WisdomToolRequirement {
name: string
minimum_version: null | string
auto_install: false
requires_admin: boolean
}
export interface WisdomPluginRequirement {
id: string
minimum_version: null | string
required: boolean
}
export interface WisdomSystemSpecification extends Record<string, unknown> {
hermes: { minimum_version: string }
platforms: string[]
architectures: string[]
model: { capabilities: string[]; minimum_context_window: null | number }
tools: WisdomToolRequirement[]
plugins: WisdomPluginRequirement[]
credentials: string[]
connections: string[]
filesystem: { read: string[]; write: string[] }
network: { destinations: string[] }
runtime: { shell: boolean; browser: boolean; code: boolean; sandbox: boolean }
hardware: string[]
known_limitations: string[]
}
export interface WisdomManifestV1 {
schema_version: 1
name: string
requirements: WisdomSystemSpecification
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {throw new Error(`${label} must be an object`)}
return value
}
function text(value: unknown, label: string): string {
if (typeof value !== 'string') {throw new Error(`${label} must be text`)}
return value
}
function nullableText(value: unknown, label: string): null | string {
if (value === null) {return null}
return text(value, label)
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') {throw new Error(`${label} must be true or false`)}
return value
}
function textList(value: unknown, label: string): string[] {
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
throw new Error(`${label} must be a list of text values`)
}
return value
}
function exactKeys(value: Record<string, unknown>, keys: string[], label: string): void {
const expected = new Set(keys)
const unexpected = Object.keys(value).filter(key => !expected.has(key))
const missing = keys.filter(key => !(key in value))
if (unexpected.length) {throw new Error(`${label} contains unsupported fields: ${unexpected.join(', ')}`)}
if (missing.length) {throw new Error(`${label} is missing fields: ${missing.join(', ')}`)}
}
export function parseWisdomSystemSpecification(value: unknown): WisdomSystemSpecification {
const specification = record(value, 'System Specification')
exactKeys(
specification,
[
'hermes',
'platforms',
'architectures',
'model',
'tools',
'plugins',
'credentials',
'connections',
'filesystem',
'network',
'runtime',
'hardware',
'known_limitations'
],
'System Specification'
)
const hermes = record(specification.hermes, 'Hermes requirement')
exactKeys(hermes, ['minimum_version'], 'Hermes requirement')
const model = record(specification.model, 'Model requirement')
exactKeys(model, ['capabilities', 'minimum_context_window'], 'Model requirement')
if (model.minimum_context_window !== null && typeof model.minimum_context_window !== 'number') {
throw new Error('Minimum context window must be a number or blank')
}
const filesystem = record(specification.filesystem, 'Filesystem requirement')
exactKeys(filesystem, ['read', 'write'], 'Filesystem requirement')
const network = record(specification.network, 'Network requirement')
exactKeys(network, ['destinations'], 'Network requirement')
const runtime = record(specification.runtime, 'Runtime requirement')
exactKeys(runtime, ['shell', 'browser', 'code', 'sandbox'], 'Runtime requirement')
if (!Array.isArray(specification.tools)) {throw new Error('Tools must be a list')}
const tools = specification.tools.map((raw, index) => {
const tool = record(raw, `Tool ${index + 1}`)
exactKeys(tool, ['name', 'minimum_version', 'auto_install', 'requires_admin'], `Tool ${index + 1}`)
if (tool.auto_install !== false) {throw new Error(`Tool ${index + 1} cannot request automatic installation`)}
return {
name: text(tool.name, `Tool ${index + 1} name`),
minimum_version: nullableText(tool.minimum_version, `Tool ${index + 1} minimum version`),
auto_install: false as const,
requires_admin: boolean(tool.requires_admin, `Tool ${index + 1} administrator requirement`)
}
})
if (!Array.isArray(specification.plugins)) {throw new Error('Plugins must be a list')}
const plugins = specification.plugins.map((raw, index) => {
const plugin = record(raw, `Plugin ${index + 1}`)
exactKeys(plugin, ['id', 'minimum_version', 'required'], `Plugin ${index + 1}`)
return {
id: text(plugin.id, `Plugin ${index + 1} ID`),
minimum_version: nullableText(plugin.minimum_version, `Plugin ${index + 1} minimum version`),
required: boolean(plugin.required, `Plugin ${index + 1} requirement`)
}
})
return {
hermes: { minimum_version: text(hermes.minimum_version, 'Minimum Hermes version') },
platforms: textList(specification.platforms, 'Platforms'),
architectures: textList(specification.architectures, 'Architectures'),
model: {
capabilities: textList(model.capabilities, 'Model capabilities'),
minimum_context_window: model.minimum_context_window === null ? null : Number(model.minimum_context_window)
},
tools,
plugins,
credentials: textList(specification.credentials, 'Credentials'),
connections: textList(specification.connections, 'Connections'),
filesystem: {
read: textList(filesystem.read, 'Readable paths'),
write: textList(filesystem.write, 'Writable paths')
},
network: { destinations: textList(network.destinations, 'Network destinations') },
runtime: {
shell: boolean(runtime.shell, 'Shell requirement'),
browser: boolean(runtime.browser, 'Browser requirement'),
code: boolean(runtime.code, 'Code execution requirement'),
sandbox: boolean(runtime.sandbox, 'Sandbox requirement')
},
hardware: textList(specification.hardware, 'Hardware'),
known_limitations: textList(specification.known_limitations, 'Known limitations')
}
}
export function parseWisdomManifest(value: string): WisdomManifestV1 {
const manifest = record(JSON.parse(value) as unknown, 'Manifest')
exactKeys(manifest, ['schema_version', 'name', 'requirements'], 'Manifest')
if (manifest.schema_version !== 1) {throw new Error('Only manifest schema version 1 is supported')}
return {
schema_version: 1,
name: text(manifest.name, 'Skill name'),
requirements: parseWisdomSystemSpecification(manifest.requirements)
}
}
function boundedTextError(value: string, label: string): null | string {
if (!value.trim()) {return `${label} is required.`}
if (new TextEncoder().encode(value).length > 512) {return `${label} must be 512 UTF-8 bytes or fewer.`}
return null
}
function listError(values: string[], label: string): null | string {
if (values.length > 64) {return `${label} can contain at most 64 entries.`}
for (const value of values) {
const error = boundedTextError(value, `${label} entry`)
if (error) {return error}
}
return null
}
export function wisdomSystemSpecificationValidationError(value: WisdomSystemSpecification): null | string {
const errors: Array<null | string> = [
boundedTextError(value.hermes.minimum_version, 'Minimum Hermes version'),
value.model.minimum_context_window !== null &&
(!Number.isSafeInteger(value.model.minimum_context_window) || value.model.minimum_context_window < 1)
? 'Minimum context window must be blank or a positive whole number.'
: null,
listError(value.platforms, 'Platforms'),
listError(value.architectures, 'Architectures'),
listError(value.model.capabilities, 'Model capabilities'),
listError(value.credentials, 'Credentials'),
listError(value.connections, 'Connections'),
listError(value.filesystem.read, 'Readable paths'),
listError(value.filesystem.write, 'Writable paths'),
listError(value.network.destinations, 'Network destinations'),
listError(value.hardware, 'Hardware requirements'),
listError(value.known_limitations, 'Known limitations'),
value.tools.length > 64 ? 'Tools can contain at most 64 entries.' : null,
value.plugins.length > 64 ? 'Plugins can contain at most 64 entries.' : null
]
for (const [index, tool] of value.tools.entries()) {
errors.push(boundedTextError(tool.name, `Tool ${index + 1} name`))
if (tool.minimum_version !== null) {errors.push(boundedTextError(tool.minimum_version, `Tool ${index + 1} version`))}
}
for (const [index, plugin] of value.plugins.entries()) {
errors.push(boundedTextError(plugin.id, `Plugin ${index + 1} ID`))
if (plugin.minimum_version !== null) {
errors.push(boundedTextError(plugin.minimum_version, `Plugin ${index + 1} version`))
}
}
return errors.find((error): error is string => Boolean(error)) ?? null
}
export function wisdomManifestValidationError(value: string): null | string {
try {
const manifest = parseWisdomManifest(value)
return boundedTextError(manifest.name, 'Skill name') ?? wisdomSystemSpecificationValidationError(manifest.requirements)
} catch (reason) {
return reason instanceof Error ? reason.message : String(reason)
}
}
@@ -28,6 +28,7 @@ interface MockComposerProps {
}
const composerRenders = vi.hoisted(() => [] as MockComposerProps[])
const wisdomSessionIds = vi.hoisted(() => [] as string[])
vi.mock('./user-edit-composer', () => ({
UserEditComposer: (props: MockComposerProps) => {
@@ -36,6 +37,16 @@ vi.mock('./user-edit-composer', () => ({
return <div data-testid="edit-composer">{props.cwd}</div>
}
}))
vi.mock('@/components/assistant-ui/wisdom-candidate-card', () => ({
WisdomCandidateCard: ({ sessionId }: { sessionId: string }) => {
wisdomSessionIds.push(sessionId)
return <div data-testid="wisdom-candidate-session">{sessionId}</div>
}
}))
vi.mock('@/components/assistant-ui/wisdom-notice-card', () => ({
WisdomNoticeCard: () => null
}))
stubThreadEnvironment()
afterEach(() => {
@@ -44,6 +55,7 @@ afterEach(() => {
beforeEach(() => {
composerRenders.length = 0
wisdomSessionIds.length = 0
})
stubThreadViewportSize()
@@ -53,7 +65,15 @@ const noopAsync = async () => {}
// The repository must stay referentially stable across rerenders: a new
// object would make the incremental runtime resync the transcript and
// unmount the open composer, defeating the test.
function Harness({ cwd, sessionKey }: { cwd: string; sessionKey: string }) {
function Harness({
cwd,
runtimeSessionId,
sessionKey
}: {
cwd: string
runtimeSessionId?: string
sessionKey: string
}) {
const [repository] = useState(() => ExportedMessageRepository.fromArray([userMessage(), assistantMessage()]))
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
@@ -68,12 +88,19 @@ function Harness({ cwd, sessionKey }: { cwd: string; sessionKey: string }) {
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread cwd={cwd} sessionKey={sessionKey} />
<Thread cwd={cwd} sessionId={runtimeSessionId} sessionKey={sessionKey} />
</AssistantRuntimeProvider>
)
}
describe('thread edit context', () => {
it('polls Wisdom with the durable session key after a runtime reconnect', async () => {
render(<Harness cwd="/repo" runtimeSessionId="runtime-owner" sessionKey="stored-session" />)
expect((await screen.findByTestId('wisdom-candidate-session')).textContent).toBe('stored-session')
expect(wisdomSessionIds.at(-1)).toBe('stored-session')
})
it('passes a same-session cwd change to the mounted edit composer', async () => {
const { rerender } = render(<Harness cwd="/old" sessionKey="k1" />)
@@ -9,9 +9,12 @@ import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
import { UserEditComposer } from '@/components/assistant-ui/thread/user-edit-composer'
import { UserMessage } from '@/components/assistant-ui/thread/user-message'
import { WisdomCandidateCard } from '@/components/assistant-ui/wisdom-candidate-card'
import { WisdomNoticeCard } from '@/components/assistant-ui/wisdom-notice-card'
import { Intro, type IntroProps } from '@/components/chat/intro'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import type { HermesGateway } from '@/hermes'
import { WisdomMediationCard } from '@/components/wisdom-mediation-card'
import type { HermesGateway, ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
@@ -45,6 +48,7 @@ interface ThreadProps {
onRestoreToMessage?: (messageId: string, target?: RestoreMessageTarget) => Promise<void> | void
sessionId?: string | null
sessionKey?: string | null
wisdomProfile?: ProfileScope
}
// memo'd on purpose, and load-bearing for session-switch cost. ChatView
@@ -65,7 +69,8 @@ export const Thread = memo(function Thread({
onDismissError,
onRestoreToMessage,
sessionId = null,
sessionKey
sessionKey,
wisdomProfile
}: ThreadProps) {
const { t } = useI18n()
const copy = t.assistant.thread
@@ -167,10 +172,30 @@ export const Thread = memo(function Thread({
// always correct.
const loadingIndicator = useMemo(() => <BackgroundResumeNotice />, [])
// Candidate events are written by the Agent against the durable stored
// session key. Desktop's runtime session id is a short-lived websocket
// owner and changes whenever a stored conversation is resumed, so polling
// with it can never hydrate a durable event after reconnect. Fresh sessions
// temporarily have no stored key, where the runtime id is the correct
// fallback until persistence binds the pair.
const wisdomSessionId = sessionKey || sessionId
const wisdomContent = useMemo(
() => (wisdomSessionId ? (
<>
<WisdomNoticeCard profile={wisdomProfile} />
<WisdomMediationCard profile={wisdomProfile} sessionId={wisdomSessionId} />
<WisdomCandidateCard profile={wisdomProfile} sessionId={wisdomSessionId} />
</>
) : undefined),
[wisdomProfile, wisdomSessionId]
)
return (
<ThreadEditContext.Provider value={editContext}>
<div className="relative grid h-full min-h-0 max-w-full grid-rows-[minmax(0,1fr)] overflow-hidden bg-transparent contain-[layout_paint]">
<ThreadMessageList
afterContent={wisdomContent}
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
@@ -207,6 +207,7 @@ export function subscribeToThreadForeground(shouldReanchor: () => boolean, onRea
}
interface ThreadMessageListProps {
afterContent?: ReactNode
clampToComposer: boolean
components: ThreadMessageComponents
emptyPlaceholder?: ReactNode
@@ -398,6 +399,7 @@ const TurnRow = memo(function TurnRow({ components, group, resetKey, virtualized
})
const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
afterContent,
clampToComposer,
components,
emptyPlaceholder,
@@ -1082,6 +1084,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
)}
{rows}
{loadingIndicator}
{afterContent}
{clampToComposer && (
<div
aria-hidden="true"
@@ -1,6 +1,6 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $displayTimestamps } from '@/store/display-timestamps'
@@ -14,6 +14,35 @@ $displayTimestamps.set(true)
const timestamp = new Date('2026-05-01T00:00:00.000Z')
stubThreadEnvironment()
const securityCheck = {
schema_version: 1,
status: 'pass',
summary: 'No known matches detected.',
checks: [
{
key: 'private_keys',
label: 'Private keys',
status: 'pass',
finding_count: 0,
details: []
}
]
}
const professionalismCheck = {
schema_version: 1,
status: 'advisory',
summary: 'One phrase may read as spam-like.',
checks: [
{
key: 'manipulative_or_spam',
status: 'advisory',
finding_count: 1,
details: ['Avoid urgency-based promotional wording.']
}
]
}
function Harness({ text, asyncResult }: { text: string; asyncResult?: string }) {
const message = {
id: 'system-1',
@@ -44,7 +73,11 @@ function expectTimestampSeparated(container: HTMLElement, precedingText: string)
expect(row?.textContent).toContain(`${precedingText} ${stamp}`)
}
afterEach(cleanup)
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'hermesDesktop')
window.location.hash = ''
})
describe('background report disclosure', () => {
it('keeps result bodies out of the transcript until opened and removes them when collapsed', () => {
@@ -84,4 +117,153 @@ describe('system message timestamp text separation', () => {
expectTimestampSeparated(container, 'rerun tests')
})
it('renders Wisdom slash output as a readable command result', () => {
const { container } = render(
<Harness text={'slash:/wisdom\nCollective Wisdom commands\n\n/wisdom browse — Search team skills'} />
)
const row = container.querySelector('[data-role="system"]')
expect(row?.className).toContain('w-[min(92%,56rem)]')
expect(row?.className).toContain('text-(--ui-text-secondary)')
expect(row?.textContent).toContain('Collective Wisdom commands')
expect(row?.textContent).toContain('/wisdom browse — Search team skills')
})
it('opens Wisdom browse results in an in-app skill preview', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined)
const api = vi.fn().mockImplementation(({ path }: { path: string }) => {
if (path.endsWith('/versions/1')) {
return Promise.resolve({
local_compatibility: { outcome: 'compatible' },
portal_url: 'https://portal.example/orgs/team/wisdom/skills/skill-1?version=1',
skill: { id: 'skill-1', slug: 'collective-wisdom-canary' },
version: {
author_description: 'The first canary release.',
commit: 'sha256:commit-1',
content_hash: 'sha256:content-1',
explanation: 'Validated as a dependency-free canary.',
package_manifest_hash: 'sha256:manifest-1',
professionalism_check: professionalismCheck,
published_at: '2026-01-02T03:04:05Z',
scan: { verdict: 'pass', findings: [] },
security_check: securityCheck,
system_spec: {
hermes: { minimum_version: '0.20.5' },
platforms: ['macOS'],
runtime: { shell: true }
},
verified_facts: { scan_verdict: 'pass' },
version: 1
}
})
}
return Promise.resolve({
latest_version_detail: {
version: {
author_description: 'Verify the Collective Wisdom canary flow.',
professionalism_check: professionalismCheck,
scan: { verdict: 'pass' },
security_check: securityCheck,
system_spec: {
hermes: { minimum_version: '0.20.5' },
platforms: ['macOS'],
runtime: { shell: true }
},
version: 2
}
},
local_compatibility: { outcome: 'compatible' },
portal_url: 'https://portal.example/orgs/team/wisdom/skills/skill-1',
skill: { id: 'skill-1', slug: 'collective-wisdom-canary' },
versions: [
{
author_description: 'Current canary release.',
professionalism_check: professionalismCheck,
published_at: '2026-02-03T04:05:06Z',
security_check: securityCheck,
verified_facts: { scan_verdict: 'pass' },
version: 2
},
{
author_description: 'The first canary release.',
professionalism_check: professionalismCheck,
published_at: '2026-01-02T03:04:05Z',
security_check: securityCheck,
verified_facts: { scan_verdict: 'pass' },
version: 1
}
]
})
})
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { api, openExternal } })
const { container } = render(
<Harness
text={
'slash:/wisdom browse\nShared skills\n\ncollective-wisdom-canary\nv2 · Verify the canary.\nView: /wisdom show collective-wisdom-canary'
}
/>
)
const preview = screen.getByRole('button', { name: 'Preview: collective-wisdom-canary' })
expect(container.textContent).not.toContain('/wisdom show collective-wisdom-canary')
expect(container.textContent).not.toContain('View:')
fireEvent.click(preview)
const dialog = await screen.findByRole('dialog', { name: /collective-wisdom-canary/ })
expect(dialog).toBeTruthy()
expect(await screen.findByText('Verify the Collective Wisdom canary flow.')).toBeTruthy()
expect(screen.getByText('Hermes ≥ 0.20.5')).toBeTruthy()
expect(screen.getByText('macOS')).toBeTruthy()
expect(screen.getByText('shell')).toBeTruthy()
expect(screen.getByText('compatible')).toBeTruthy()
expect(within(dialog).getByRole('region', { name: 'Security check' })).toBeTruthy()
expect(within(dialog).getByRole('region', { name: 'Professionalism check' })).toBeTruthy()
expect(within(dialog).getByText('Private keys')).toBeTruthy()
expect(within(dialog).getByText('Manipulative, deceptive, or spam-like wording')).toBeTruthy()
expect(
within(dialog).getByText('Agent-assessed and advisory. It does not block publication or installation.')
).toBeTruthy()
expect(within(dialog).getByRole('button', { name: 'Versions' })).toBeTruthy()
expect(within(dialog).getByRole('button', { name: 'View in Portal' })).toBeTruthy()
await waitFor(() =>
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/wisdom/skills/collective-wisdom-canary' }))
)
fireEvent.click(within(dialog).getByRole('button', { name: 'View in Portal' }))
expect(openExternal).toHaveBeenCalledWith('https://portal.example/orgs/team/wisdom/skills/skill-1')
fireEvent.click(within(dialog).getByRole('button', { name: 'Versions' }))
const firstVersion = within(dialog).getByRole('button', { name: /v1.*The first canary release/ })
expect(firstVersion).toBeTruthy()
fireEvent.click(firstVersion)
expect(await within(dialog).findByText('Validated as a dependency-free canary.')).toBeTruthy()
expect(within(dialog).getByText('sha256:content-1')).toBeTruthy()
expect(within(dialog).getByText('sha256:manifest-1')).toBeTruthy()
await waitFor(() =>
expect(api).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/wisdom/skills/skill-1/versions/1' }))
)
fireEvent.click(within(dialog).getByRole('button', { name: 'View in Portal' }))
expect(openExternal).toHaveBeenLastCalledWith('https://portal.example/orgs/team/wisdom/skills/skill-1?version=1')
fireEvent.click(within(dialog).getByRole('button', { name: /^Install/ }))
expect(window.location.hash).toBe('#/skills?tab=collective&wisdomAction=install&wisdomSkillId=skill-1%40v1')
})
it('keeps non-Wisdom multiline slash output on the compact system-row treatment', () => {
const { container } = render(<Harness text={'slash:/status\nGateway status\nReady'} />)
const row = container.querySelector('[data-role="system"]')
expect(row?.className).toContain('w-[60%]')
expect(row?.className).toContain('text-muted-foreground/60')
expect(row?.className).not.toContain('w-[min(92%,56rem)]')
expect(row?.className).not.toContain('text-(--ui-text-secondary)')
})
})
@@ -10,6 +10,8 @@ import { ToolIcon } from '@/components/ui/tool-icon'
import { LinkifiedText } from '@/lib/external-link'
import { cn } from '@/lib/utils'
import { WisdomCommandOutput } from './wisdom-command-output'
const SLASH_STATUS_RE = /^slash:(?<command>\/[^\n]+)\n(?<output>[\s\S]*)$/
const STEER_NOTE_RE = /^steer:(?<text>[\s\S]+)$/
const REVIEW_NOTE_RE = /^review:(?<label>[^:\n]+):?\s*(?<detail>[\s\S]*)$/
@@ -106,18 +108,35 @@ export const SystemMessage: FC = () => {
// multiline output (catalogs, usage tables) needs left-aligned, wider room
// or the column alignment breaks.
const multiline = output.includes('\n')
const wisdomOutput = /^\/(?:wisdom|collective-wisdom-install)(?:\s|$)/i.test(slashStatus.groups.command)
const [headline, ...detailLines] = wisdomOutput ? output.split('\n') : [output]
const detail = detailLines.join('\n').trim()
return (
<MessagePrimitive.Root
className={cn(
'w-[60%] max-w-[44rem] self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
multiline ? 'text-left' : 'text-center'
'self-center',
wisdomOutput
? 'w-[min(92%,56rem)] rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-left text-[0.75rem] leading-[1.55] text-(--ui-text-secondary)'
: cn(
'w-[60%] max-w-[44rem] px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/60',
multiline ? 'text-left' : 'text-center'
)
)}
data-role="system"
data-slot="aui_system-message-root"
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
{multiline ? (
<span
className={cn(
'font-mono',
wisdomOutput ? 'block text-[0.6875rem] text-(--ui-text-tertiary)' : 'text-muted-foreground/55'
)}
>
{slashStatus.groups.command}
</span>
{wisdomOutput ? (
<WisdomCommandOutput detail={detail} headline={headline} />
) : multiline ? (
<LinkifiedText className="mt-0.5 block whitespace-pre-wrap" explicitOnly pretty={false} text={output} />
) : (
<>
@@ -0,0 +1,599 @@
import { useEffect, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { WisdomCheckBadge, WisdomReviewTables } from '@/components/wisdom-checks'
import {
getWisdomSkill,
getWisdomVersion,
type WisdomReviewCheck,
type WisdomSkillDetail,
type WisdomVersionDetail
} from '@/hermes'
import { useI18n } from '@/i18n'
import { LinkifiedText, openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ChevronRight, Clock, Download, ExternalLink, Eye, Loader2Icon } from '@/lib/icons'
const WISDOM_VIEW_RE = /^View:\s+\/wisdom\s+show\s+(\S+)\s*$/i
type PreviewState =
| { detail: null; error: null; status: 'idle' | 'loading' }
| { detail: null; error: string; status: 'error' }
| { detail: WisdomSkillDetail; error: null; status: 'ready' }
type VersionState =
| { detail: null; error: null; status: 'idle' | 'loading' }
| { detail: null; error: string; status: 'error' }
| { detail: WisdomVersionDetail; error: null; status: 'ready' }
interface VersionSummary {
authorDescription: string
explanation: string
publishedAt: string
scanVerdict: string
securityCheck?: WisdomReviewCheck
professionalismCheck?: WisdomReviewCheck
version: number
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function asText(...values: unknown[]): string {
return String(values.find(value => typeof value === 'string' && value.trim()) ?? '').trim()
}
function versionNumber(value: unknown): null | number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function labelToken(value: string): string {
return value.replaceAll('_', ' ').replaceAll('-', ' ')
}
function formatPublishedAt(value: string): string {
if (!value) {
return ''
}
const date = new Date(value)
return Number.isNaN(date.valueOf())
? value
: new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function parseVersionSummary(value: Record<string, unknown>): null | VersionSummary {
const version = versionNumber(value.version)
if (!version) {
return null
}
const verifiedFacts = asRecord(value.verified_facts)
const scan = asRecord(value.scan)
return {
authorDescription: asText(value.author_description, value.authorDescription),
explanation: asText(value.explanation),
publishedAt: asText(value.published_at, value.created_at),
scanVerdict: asText(verifiedFacts.scan_verdict, scan.verdict),
securityCheck: value.security_check as WisdomReviewCheck | undefined,
professionalismCheck: value.professionalism_check as WisdomReviewCheck | undefined,
version
}
}
function requirementsFrom(specification: Record<string, unknown>): string[] {
const requirements = asRecord(specification.requirements)
const source = Object.keys(requirements).length ? requirements : specification
const hermes = asRecord(source.hermes)
const runtime = asRecord(source.runtime)
const minimumVersion = asText(hermes.minimum_version)
const list = (key: string) =>
Array.isArray(source[key])
? source[key].filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
: []
return [
...(minimumVersion ? [`Hermes ≥ ${minimumVersion}`] : []),
...list('platforms'),
...list('architectures'),
...Object.entries(runtime)
.filter(([, enabled]) => enabled === true)
.map(([name]) => labelToken(name))
]
}
function MetadataBadges({
compatibility,
scanVerdict,
version
}: {
compatibility: string
scanVerdict: string
version: null | number
}) {
return (
<div className="flex flex-wrap gap-1.5">
{version ? <Badge variant="outline">v{version}</Badge> : null}
{scanVerdict ? <Badge>{labelToken(scanVerdict)}</Badge> : null}
{compatibility ? <Badge variant="muted">{labelToken(compatibility)}</Badge> : null}
</div>
)
}
function Requirements({ label, specification }: { label: string; specification: Record<string, unknown> }) {
const requirements = requirementsFrom(specification)
if (!requirements.length && !Object.keys(specification).length) {
return null
}
return (
<section aria-label={label}>
<h3 className="text-xs font-medium">{label}</h3>
{requirements.length ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{requirements.map(requirement => (
<Badge key={requirement} variant="outline">
{requirement}
</Badge>
))}
</div>
) : (
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 text-[0.67rem] text-muted-foreground">
{JSON.stringify(specification, null, 2)}
</pre>
)}
</section>
)
}
function LoadingState({ label }: { label: string }) {
return (
<div className="flex min-h-32 items-center justify-center gap-2 text-xs text-muted-foreground" role="status">
<Loader2Icon className="size-4 animate-spin" />
{label}
</div>
)
}
function ErrorState({ error }: { error: string }) {
return (
<div
className="rounded-md border border-destructive/50 bg-destructive/8 px-3 py-2 text-xs text-destructive"
role="alert"
>
{error}
</div>
)
}
function WisdomSkillPreview({ onClose, skillId }: { onClose: () => void; skillId: string }) {
const { t } = useI18n()
const copy = t.skills.collective
const [state, setState] = useState<PreviewState>({ detail: null, error: null, status: 'idle' })
const [showVersions, setShowVersions] = useState(false)
const [selectedVersion, setSelectedVersion] = useState<null | number>(null)
const [versionState, setVersionState] = useState<VersionState>({ detail: null, error: null, status: 'idle' })
useEffect(() => {
let current = true
setState({ detail: null, error: null, status: 'loading' })
void getWisdomSkill(skillId)
.then(detail => {
if (current) {
setState({ detail, error: null, status: 'ready' })
}
})
.catch(error => {
if (current) {
setState({
detail: null,
error: error instanceof Error ? error.message : copy.unavailable,
status: 'error'
})
}
})
return () => {
current = false
}
}, [copy.unavailable, skillId])
const preview = useMemo(() => {
if (state.status !== 'ready') {
return null
}
const skill = asRecord(state.detail.skill)
const latest = asRecord(state.detail.latest_version_detail)
const version = asRecord(latest.version)
const compatibility = asRecord(state.detail.local_compatibility)
const installation = asRecord(state.detail.local_installation)
const scan = asRecord(version.scan)
const verifiedFacts = asRecord(version.verified_facts)
const systemSpec = asRecord(version.system_spec)
const versions = state.detail.versions
.map(item => parseVersionSummary(asRecord(item)))
.filter((item): item is VersionSummary => item !== null)
.sort((a, b) => b.version - a.version)
const latestVersion = versionNumber(version.version) ?? versions[0]?.version ?? null
return {
compatibility: asText(compatibility.outcome),
description: asText(
skill.authorDescription,
skill.author_description,
version.authorDescription,
version.author_description
),
installId: asText(skill.id, skillId) || skillId,
installedVersion: versionNumber(installation.version),
installedUpdateMode: asText(installation.update_mode),
latestVersion,
portalUrl: asText(state.detail.portal_url),
scanVerdict: asText(scan.verdict, verifiedFacts.scan_verdict),
securityCheck: version.security_check as WisdomReviewCheck | undefined,
professionalismCheck: version.professionalism_check as WisdomReviewCheck | undefined,
slug: asText(skill.slug, skill.id, skillId) || skillId,
systemSpec,
versions
}
}, [skillId, state])
useEffect(() => {
if (!selectedVersion || !preview) {
setVersionState({ detail: null, error: null, status: 'idle' })
return
}
let current = true
setVersionState({ detail: null, error: null, status: 'loading' })
void getWisdomVersion(preview.installId, selectedVersion)
.then(detail => {
if (current) {
setVersionState({ detail, error: null, status: 'ready' })
}
})
.catch(error => {
if (current) {
setVersionState({
detail: null,
error: error instanceof Error ? error.message : copy.unavailable,
status: 'error'
})
}
})
return () => {
current = false
}
}, [copy.unavailable, preview, selectedVersion])
const versionPreview = useMemo(() => {
if (versionState.status !== 'ready') {
return null
}
const version = asRecord(versionState.detail.version)
const compatibility = asRecord(versionState.detail.local_compatibility)
const scan = asRecord(version.scan)
const verifiedFacts = asRecord(version.verified_facts)
return {
authorDescription: asText(version.author_description, version.authorDescription),
commit: asText(version.commit),
compatibility: asText(compatibility.outcome),
contentHash: asText(version.content_hash),
explanation: asText(version.explanation),
packageManifestHash: asText(version.package_manifest_hash),
portalUrl: asText(versionState.detail.portal_url),
publishedAt: asText(version.published_at),
scan,
scanVerdict: asText(scan.verdict, verifiedFacts.scan_verdict),
securityCheck: version.security_check as WisdomReviewCheck | undefined,
professionalismCheck: version.professionalism_check as WisdomReviewCheck | undefined,
systemSpec: asRecord(version.system_spec),
verifiedFacts,
version: versionNumber(version.version)
}
}, [versionState])
const startInstall = (version?: number) => {
if (!preview) {
return
}
const params = new URLSearchParams({
tab: 'collective',
wisdomAction: 'install',
wisdomSkillId: version ? `${preview.installId}@v${version}` : preview.installId
})
onClose()
window.location.hash = `#/skills?${params.toString()}`
}
const returnToOverview = () => {
setSelectedVersion(null)
setShowVersions(false)
}
const returnToVersions = () => {
setSelectedVersion(null)
setShowVersions(true)
}
const screen = selectedVersion ? 'version' : showVersions ? 'versions' : 'overview'
const portalUrl = screen === 'version' ? versionPreview?.portalUrl : preview?.portalUrl
const dialogBadge =
screen === 'version' && selectedVersion
? `v${selectedVersion}`
: screen === 'versions'
? copy.versions
: copy.preview
const dialogDescription =
screen === 'version' ? versionPreview?.authorDescription : screen === 'overview' ? preview?.description : ''
return (
<Dialog onOpenChange={open => !open && onClose()} open>
<DialogContent className="max-w-[42rem]" onOpenAutoFocus={event => event.preventDefault()}>
<DialogHeader>
<DialogTitle className="flex min-w-0 items-center gap-2 pr-6">
<span className="min-w-0 truncate font-mono">{preview?.slug || skillId}</span>
<Badge className="shrink-0" variant="muted">
{dialogBadge}
</Badge>
</DialogTitle>
{dialogDescription ? (
<DialogDescription className="text-left leading-5">{dialogDescription}</DialogDescription>
) : null}
</DialogHeader>
<div className="max-h-[min(65vh,38rem)] overflow-y-auto pr-1">
{state.status === 'loading' || state.status === 'idle' ? <LoadingState label={copy.loading} /> : null}
{state.status === 'error' ? <ErrorState error={state.error} /> : null}
{preview && screen === 'overview' ? (
<div className="grid gap-4">
<MetadataBadges
compatibility={preview.compatibility}
scanVerdict={preview.scanVerdict}
version={preview.latestVersion}
/>
<div className="flex flex-wrap gap-1.5">
<WisdomCheckBadge label="Security" value={preview.securityCheck} />
<WisdomCheckBadge label="Professionalism" value={preview.professionalismCheck} />
</div>
<WisdomReviewTables professionalism={preview.professionalismCheck} security={preview.securityCheck} />
<Requirements label={copy.systemSpecification} specification={preview.systemSpec} />
{preview.versions.length ? (
<section aria-label={copy.versionHistory}>
<h3 className="text-xs font-medium">{copy.versionHistory}</h3>
<p className="mt-1 text-[0.68rem] text-muted-foreground">
{preview.versions.map(item => `v${item.version}`).join(' · ')}
</p>
</section>
) : null}
</div>
) : null}
{preview && screen === 'versions' ? (
<section aria-label={copy.versionHistory}>
<div className="divide-y divide-(--ui-stroke-tertiary) border-y border-(--ui-stroke-tertiary)">
{preview.versions.map(item => (
<button
className="row-hover flex w-full items-start justify-between gap-4 px-1 py-3 text-left focus-visible:outline focus-visible:outline-2"
key={item.version}
onClick={() => setSelectedVersion(item.version)}
type="button"
>
<span className="min-w-0">
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">v{item.version}</span>
{item.scanVerdict ? <Badge>{labelToken(item.scanVerdict)}</Badge> : null}
<WisdomCheckBadge label="Security" value={item.securityCheck} />
<WisdomCheckBadge label="Professionalism" value={item.professionalismCheck} />
</span>
<span className="mt-1 block text-xs leading-5 text-(--ui-text-secondary)">
{item.authorDescription || item.explanation || copy.immutableVersion}
</span>
{item.publishedAt ? (
<time className="mt-1 block text-[0.65rem] text-muted-foreground" dateTime={item.publishedAt}>
{copy.published(formatPublishedAt(item.publishedAt))}
</time>
) : null}
</span>
<ChevronRight className="mt-1 size-4 shrink-0 text-muted-foreground" />
</button>
))}
</div>
</section>
) : null}
{screen === 'version' && (versionState.status === 'idle' || versionState.status === 'loading') ? (
<LoadingState label={copy.loading} />
) : null}
{screen === 'version' && versionState.status === 'error' ? <ErrorState error={versionState.error} /> : null}
{screen === 'version' && versionPreview ? (
<div className="grid gap-4">
<MetadataBadges
compatibility={versionPreview.compatibility}
scanVerdict={versionPreview.scanVerdict}
version={versionPreview.version}
/>
<WisdomReviewTables
professionalism={versionPreview.professionalismCheck}
security={versionPreview.securityCheck}
/>
{versionPreview.publishedAt ? (
<time className="text-xs text-muted-foreground" dateTime={versionPreview.publishedAt}>
{copy.published(formatPublishedAt(versionPreview.publishedAt))}
</time>
) : null}
<Requirements label={copy.systemSpecification} specification={versionPreview.systemSpec} />
{versionPreview.explanation ? (
<section aria-label={copy.releaseExplanation}>
<h3 className="text-xs font-medium">{copy.releaseExplanation}</h3>
<p className="mt-1 text-xs leading-5 text-(--ui-text-secondary)">{versionPreview.explanation}</p>
</section>
) : null}
{Object.keys(versionPreview.scan).length || Object.keys(versionPreview.verifiedFacts).length ? (
<section aria-label={copy.serverFactsLabel}>
<h3 className="text-xs font-medium">{copy.serverFactsLabel}</h3>
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 text-[0.67rem] text-muted-foreground">
{JSON.stringify(
{ scan: versionPreview.scan, verified_facts: versionPreview.verifiedFacts },
null,
2
)}
</pre>
</section>
) : null}
{versionPreview.commit || versionPreview.contentHash || versionPreview.packageManifestHash ? (
<section aria-label={copy.immutableVersion}>
<h3 className="text-xs font-medium">{copy.immutableVersion}</h3>
<dl className="mt-2 grid gap-2 text-[0.67rem]">
{versionPreview.commit ? (
<div>
<dt className="text-muted-foreground">commit</dt>
<dd className="break-all font-mono">{versionPreview.commit}</dd>
</div>
) : null}
{versionPreview.contentHash ? (
<div>
<dt className="text-muted-foreground">{copy.contentHash}</dt>
<dd className="break-all font-mono">{versionPreview.contentHash}</dd>
</div>
) : null}
{versionPreview.packageManifestHash ? (
<div>
<dt className="text-muted-foreground">{copy.packageManifestHash}</dt>
<dd className="break-all font-mono">{versionPreview.packageManifestHash}</dd>
</div>
) : null}
</dl>
</section>
) : null}
</div>
) : null}
</div>
<DialogFooter className="sm:justify-between">
<div className="mr-auto flex items-center gap-2">
{screen === 'versions' ? (
<Button onClick={returnToOverview} size="sm" variant="ghost">
<ChevronLeft className="size-3.5" />
{copy.backToSkill}
</Button>
) : null}
{screen === 'version' ? (
<Button onClick={returnToVersions} size="sm" variant="ghost">
<ChevronLeft className="size-3.5" />
{copy.backToVersions}
</Button>
) : null}
{screen === 'overview' && preview?.installedVersion ? (
<span className="self-center text-[0.68rem] text-muted-foreground">
{copy.installed(preview.installedVersion, preview.installedUpdateMode || 'MANUAL')}
</span>
) : null}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button onClick={onClose} size="sm" variant="outline">
{copy.close}
</Button>
{screen === 'overview' && preview?.versions.length ? (
<Button onClick={() => setShowVersions(true)} size="sm" variant="outline">
<Clock className="size-3.5" />
{copy.versions}
</Button>
) : null}
{portalUrl ? (
<Button onClick={() => openExternalLink(portalUrl)} size="sm" variant="outline">
<ExternalLink className="size-3.5" />
{copy.viewInPortal}
</Button>
) : null}
{screen === 'overview' && preview && !preview.installedVersion ? (
<Button onClick={() => startInstall()} size="sm">
<Download className="size-3.5" />
{copy.install}
</Button>
) : null}
{screen === 'version' && preview && selectedVersion ? (
<Button onClick={() => startInstall(selectedVersion)} size="sm">
<Download className="size-3.5" />
{copy.install}
</Button>
) : null}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function WisdomCommandOutput({ detail, headline }: { detail: string; headline: string }) {
const { t } = useI18n()
const copy = t.skills.collective
const [previewSkillId, setPreviewSkillId] = useState<null | string>(null)
const lines = detail.split('\n')
return (
<>
<div className="mt-1.5 block">
<LinkifiedText className="block font-medium text-foreground/90" explicitOnly pretty={false} text={headline} />
{detail ? (
<div className="mt-1 whitespace-pre-wrap">
{lines.map((line, index) => {
const view = line.match(WISDOM_VIEW_RE)
return (
<span key={`${index}:${line}`}>
{view ? (
<Button
aria-label={`${copy.preview}: ${view[1]}`}
onClick={() => setPreviewSkillId(view[1])}
size="inline"
variant="link"
>
<Eye className="size-3" />
{copy.preview}
</Button>
) : (
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={line} />
)}
{index < lines.length - 1 ? '\n' : null}
</span>
)
})}
</div>
) : null}
</div>
{previewSkillId ? <WisdomSkillPreview onClose={() => setPreviewSkillId(null)} skillId={previewSkillId} /> : null}
</>
)
}
@@ -0,0 +1,271 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesApi from '@/hermes'
const getWisdomEvents = vi.fn()
const prepareWisdomCandidate = vi.fn()
const approveWisdomCandidate = vi.fn()
const deferWisdomCandidate = vi.fn()
const suggestWisdomSkill = vi.fn()
const saveWisdomPreparedDraft = vi.fn()
const reviewWisdomDraft = vi.fn()
const reviseWisdomDraft = vi.fn()
const decideWisdomDraft = vi.fn()
const reviewWisdomPublication = vi.fn()
const submitWisdomPublication = vi.fn()
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<typeof HermesApi>()),
reviewWisdomPublication,
submitWisdomPublication,
approveWisdomCandidate,
decideWisdomDraft,
deferWisdomCandidate,
getWisdomEvents,
prepareWisdomCandidate,
reviewWisdomDraft,
reviseWisdomDraft,
saveWisdomPreparedDraft,
suggestWisdomSkill
}))
vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() }))
const candidate = {
id: 'event-1',
kind: 'wisdom.candidate',
session_id: 'session-1',
task_id: 'task-1',
content_hash: 'sha256:local',
qualification_sequence: 1,
notice_variant: 'first',
organization_name: 'Nous Research',
payload: {
skill_name: 'safe-skill',
editorial_name: 'Safe Skill',
editorial_description: 'Share a dependable workflow with your team.',
qualification: 'meaningful_refinements',
local_reasons: { meaningful_refinements: 3 },
consent_required: true,
networked: false
}
}
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: []
}
const manifest = `${JSON.stringify({ schema_version: 1, name: 'safe-skill', requirements: systemSpecification })}\n`
const localScan = {
guard: { allowed: true, findings: [] },
skill_evaluator: { status: 'available', findings: [] }
}
const prepared = (skill = '# Safe\n') => ({
network_submission: false as const,
local_draft_id: 'local-1',
overlay_path: '/private/overlay',
drafted_description: 'Owner copy',
system_specification: systemSpecification,
files: [
{ path: 'SKILL.md', mode: 'file' as const, hash: 'sha256:local-skill', content_utf8: skill },
{ path: 'skill.manifest.json', mode: 'file' as const, hash: 'sha256:local-manifest', content_utf8: manifest }
],
local_scan: localScan,
next_step: 'review'
})
const exactReview = (id = 'draft-1', skill = '# Server reviewed\n') => ({
draft: {
id,
slug: 'safe-skill',
state: 'ready',
updatedAt: `revision-${id}`,
authorDescription: 'Owner-authored claim',
scanVerdict: 'pass',
scan: { verdict: 'pass' },
explanation: 'Server facts only',
systemSpec: systemSpecification
},
effective_policy: {},
files: [
{ path: 'SKILL.md', mode: 'file' as const, hash: `sha256:${id}-skill`, content_utf8: skill },
{ path: 'skill.manifest.json', mode: 'file' as const, hash: `sha256:${id}-manifest`, content_utf8: manifest }
],
hashes: {
content: `sha256:${id}-content`,
author_description: `sha256:${id}-copy`,
package_manifest: `sha256:${id}-manifest`
},
receipt: null
})
async function renderCard() {
const { WisdomCandidateCard } = await import('./wisdom-candidate-card')
return render(
<div data-slot="aui_thread-viewport">
<WisdomCandidateCard profile="research" sessionId="session-1" />
</div>
)
}
beforeEach(() => {
vi.resetAllMocks()
window.location.hash = '#/session-1'
getWisdomEvents.mockResolvedValue({ events: [candidate] })
prepareWisdomCandidate.mockResolvedValue({
stage: 'prepared',
prepared: prepared(),
local_skill_id: 'local-skill-1',
skill_name: 'safe-skill'
})
suggestWisdomSkill.mockResolvedValue(prepared())
reviewWisdomPublication.mockResolvedValue({
...exactReview('local-1'),
draft: { ...exactReview('local-1').draft, state: 'prepared' },
publication_mode: 'moderated'
})
submitWisdomPublication.mockResolvedValue({
draft_id: 'draft-1',
publication_state: 'pending_moderation',
portal_url: 'https://portal.example/review/draft-1'
})
})
afterEach(() => {
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('WisdomCandidateCard', () => {
it('opens the entire local package before one final moderation submission', async () => {
await renderCard()
expect(await screen.findByText(/Your organization \(Nous Research\) has enabled Collective Wisdom/)).toBeTruthy()
expect(screen.getByText(/Congratulations! Hermes detected a skill/)).toBeTruthy()
expect(screen.getByText('Skill name: Safe Skill')).toBeTruthy()
expect(screen.getByText('What it does: Share a dependable workflow with your team.')).toBeTruthy()
expect(screen.queryByText(/Nothing is shared without your approval/)).toBeNull()
expect(screen.queryByText(/Reusable skill ready to review/)).toBeNull()
expect(screen.queryByDisplayValue('Owner copy')).toBeNull()
expect(screen.queryByLabelText('Edit SKILL.md')).toBeNull()
expect(screen.queryByText('Minimum Hermes version')).toBeNull()
expect(screen.getByText('Would you like to share it?')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Review first' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Not Now' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
expect(submitWisdomPublication).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Review first' }))
const submit = await screen.findByRole('button', { name: 'Submit for approval' })
expect(screen.getByLabelText('Edit SKILL.md')).toBeTruthy()
expect(screen.getByText('Minimum Hermes version')).toBeTruthy()
await waitFor(() => expect(submit).toHaveProperty('disabled', false))
expect(prepareWisdomCandidate).toHaveBeenCalledWith('event-1', 'research')
fireEvent.click(submit)
expect(await screen.findByText('Waiting for collective administrator approval')).toBeTruthy()
expect(submitWisdomPublication).toHaveBeenCalledWith(
expect.objectContaining({
hashes: exactReview('local-1').hashes,
publication_mode: 'moderated'
}),
'research',
undefined
)
expect(suggestWisdomSkill).not.toHaveBeenCalled()
expect(approveWisdomCandidate).not.toHaveBeenCalled()
}, 30_000)
it('blocks unsaved edits, rescans locally, then submits the new hashes', async () => {
await renderCard()
fireEvent.click(await screen.findByRole('button', { name: 'Review first' }))
fireEvent.change(await screen.findByLabelText('Edit SKILL.md'), { target: { value: '# Edited' } })
expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', true)
saveWisdomPreparedDraft.mockResolvedValue(prepared())
const updated = {
...exactReview('saved', '# Edited'),
draft: { ...exactReview('saved').draft, state: 'prepared' },
publication_mode: 'moderated'
}
reviewWisdomPublication.mockResolvedValue(updated)
fireEvent.click(screen.getByRole('button', { name: 'Save changes & rescan' }))
await waitFor(() => expect(saveWisdomPreparedDraft).toHaveBeenCalledTimes(1))
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Submit for approval' })).toHaveProperty('disabled', false)
)
fireEvent.click(screen.getByRole('button', { name: 'Submit for approval' }))
await waitFor(() => expect(submitWisdomPublication).toHaveBeenCalledWith(updated, 'research', undefined))
})
it('uses the open-policy label and keeps failures available for retry', async () => {
reviewWisdomPublication.mockResolvedValue({ ...exactReview(), publication_mode: 'open' })
submitWisdomPublication.mockRejectedValueOnce(new Error('Package changed; reload review'))
await renderCard()
fireEvent.click(await screen.findByRole('button', { name: 'Review first' }))
const button = await screen.findByRole('button', { name: 'Publish to team' })
fireEvent.click(button)
expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Package changed; reload review')
expect(screen.getByRole('button', { name: 'Reload review' })).toBeTruthy()
expect(button).toHaveProperty('disabled', false)
expect(screen.queryByRole('link', { name: 'View in Portal' })).toBeNull()
})
it('defers this notification without declining the qualified candidate', async () => {
deferWisdomCandidate.mockResolvedValue({ event_id: 'event-1', state: 'deferred' })
await renderCard()
fireEvent.click(await screen.findByRole('button', { name: 'Not Now' }))
await waitFor(() => expect(deferWisdomCandidate).toHaveBeenCalledWith('event-1', 'research'))
expect(screen.queryByText('safe-skill')).toBeNull()
})
it('Share opens local review and never approves an unseen package', async () => {
await renderCard()
fireEvent.click(await screen.findByRole('button', { name: 'Share' }))
await screen.findByRole('button', { name: 'Submit for approval' })
expect(prepareWisdomCandidate).toHaveBeenCalledWith('event-1', 'research')
expect(approveWisdomCandidate).not.toHaveBeenCalled()
expect(submitWisdomPublication).not.toHaveBeenCalled()
})
it('shows the notification mute placeholder as a local visual toggle', async () => {
await renderCard()
const mute = await screen.findByRole('button', { name: 'Mute notifications (coming soon)' })
expect(mute.getAttribute('aria-pressed')).toBe('false')
fireEvent.click(mute)
const unmute = screen.getByRole('button', { name: 'Unmute notifications (coming soon)' })
expect(unmute.getAttribute('aria-pressed')).toBe('true')
expect(deferWisdomCandidate).not.toHaveBeenCalled()
expect(approveWisdomCandidate).not.toHaveBeenCalled()
})
it('keeps the qualification card actionable when exact-package preparation fails', async () => {
prepareWisdomCandidate.mockRejectedValueOnce(new Error('Gateway temporarily unavailable'))
await renderCard()
fireEvent.click(await screen.findByRole('button', { name: 'Review first' }))
expect((await screen.findByRole('alert')).textContent).toContain('Gateway temporarily unavailable')
expect((screen.getByRole('button', { name: 'Not Now' }) as HTMLButtonElement).disabled).toBe(false)
expect((screen.getByRole('button', { name: 'Review first' }) as HTMLButtonElement).disabled).toBe(false)
expect((screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Review first' }))
expect(await screen.findByRole('button', { name: 'Submit for approval' })).toBeTruthy()
expect(prepareWisdomCandidate).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,215 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { Button } from '@/components/ui/button'
import { WisdomPublicationReview } from '@/components/wisdom-publication-review'
import {
deferWisdomCandidate,
getWisdomEvents,
prepareWisdomCandidate,
type ProfileScope,
type WisdomCandidateEvent
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Volume2, VolumeX } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
const AUTO_REVEAL_PREVIOUS_BOTTOM_GAP_PX = 48
export function WisdomCandidateCard({ profile, sessionId }: { profile?: ProfileScope; sessionId: string }) {
const { t } = useI18n()
const copy = t.skills.collective
const [event, setEvent] = useState<null | WisdomCandidateEvent>(null)
const [draftId, setDraftId] = useState<string | null>(null)
const [notificationsMuted, setNotificationsMuted] = useState(false)
const [busy, setBusy] = useState<null | 'defer' | 'prepare'>(null)
const [preparationError, setPreparationError] = useState<null | string>(null)
const cardRef = useRef<HTMLElement>(null)
const resolvedEventIdsRef = useRef(new Set<string>())
const eventId = event?.id
useEffect(() => {
let active = true
let refreshSequence = 0
resolvedEventIdsRef.current.clear()
const refresh = async () => {
const sequence = ++refreshSequence
try {
const result = await getWisdomEvents(sessionId, profile)
if (active && sequence === refreshSequence) {
const pending = result.events.filter(item => !resolvedEventIdsRef.current.has(item.id))
setEvent(current =>
current && resolvedEventIdsRef.current.has(current.id)
? current
: (pending.find(item => item.id === current?.id) ?? pending[0] ?? null)
)
}
} catch {
// Candidate promotion is optional transcript UI. Wisdom availability
// must never make ordinary chat unusable. Preserve the last confirmed
// event so a transient refresh cannot make the card flicker away.
}
}
void refresh()
const timer = window.setInterval(() => void refresh(), 10_000)
return () => {
active = false
window.clearInterval(timer)
}
}, [profile, sessionId])
useEffect(() => {
setDraftId(null)
setPreparationError(null)
setNotificationsMuted(false)
}, [eventId])
useLayoutEffect(() => {
const card = cardRef.current
if (!eventId || !card) {
return
}
const viewport = card.closest<HTMLElement>('[data-slot="aui_thread-viewport"]')
if (!viewport || typeof card.scrollIntoView !== 'function') {
return
}
const cardHeight = card.getBoundingClientRect().height || card.offsetHeight
const previousBottomGap = viewport.scrollHeight - cardHeight - viewport.scrollTop - viewport.clientHeight
if (previousBottomGap <= AUTO_REVEAL_PREVIOUS_BOTTOM_GAP_PX) {
card.scrollIntoView({ block: 'nearest' })
}
}, [eventId])
if (!event) {
return null
}
const skill = event.payload.skill_name
const displayName = event.payload.editorial_name?.trim() || skill
const displayDescription = event.payload.editorial_description?.trim()
const qualificationNotice =
event.notice_variant === 'first' ? copy.qualificationFirst(event.organization_name) : copy.qualificationReturning
const prepareForReview = async () => {
setBusy('prepare')
setPreparationError(null)
try {
const result = await prepareWisdomCandidate(event.id, profile)
setDraftId(result.stage === 'review' ? result.review.draft.id : result.prepared.local_draft_id)
} catch (error) {
setPreparationError(error instanceof Error ? error.message : String(error))
} finally {
setBusy(null)
}
}
const notNow = async () => {
setBusy('defer')
try {
await deferWisdomCandidate(event.id, profile)
resolvedEventIdsRef.current.add(event.id)
setDraftId(null)
setEvent(null)
} catch (error) {
notifyError(error, 'Collective Wisdom notification could not be deferred')
} finally {
setBusy(null)
}
}
return (
<section
aria-label={copy.proposalTitle}
className="mb-(--conversation-turn-gap) border border-emerald-600/40 bg-(--ui-chat-surface-background)"
ref={cardRef}
>
<header className="flex items-center justify-between border-b border-(--ui-stroke-tertiary) px-4 py-3">
<div>
<div className="text-xs font-medium">{copy.proposalTitle}</div>
<div className="text-[0.68rem] font-medium text-muted-foreground">
{copy.skillName}: {displayName}
</div>
</div>
<span className="text-[0.62rem] text-muted-foreground">{copy.localSuggestion}</span>
</header>
<div className="border-b border-(--ui-stroke-tertiary) px-4 py-3">
<p className="text-xs leading-5">{qualificationNotice}</p>
{displayDescription && (
<p className="mt-1 text-xs text-muted-foreground">
{copy.whatItDoes}: {displayDescription}
</p>
)}
<p className="mt-2 text-xs font-medium">{copy.sharePrompt}</p>
</div>
{!draftId && (
<div className="p-4">
{busy === 'prepare' && <p className="mt-3 text-xs">{copy.preparingLocal}</p>}
{preparationError && (
<div className="mt-3 text-xs text-destructive" role="alert">
{preparationError}
</div>
)}
<footer className="mt-3 grid grid-cols-3 items-center gap-2 border-t border-(--ui-stroke-tertiary) pt-3">
<div className="flex items-center gap-1 justify-self-start">
<TooltipIconButton
aria-pressed={notificationsMuted}
disabled={busy !== null}
onClick={() => setNotificationsMuted(value => !value)}
tooltip={notificationsMuted ? copy.unmuteNotificationsSoon : copy.muteNotificationsSoon}
>
{notificationsMuted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</TooltipIconButton>
<Button disabled={busy !== null} onClick={() => void notNow()} size="sm" variant="outline">
{copy.notNow}
</Button>
</div>
<div className="justify-self-center">
<Button disabled={busy !== null} onClick={() => void prepareForReview()} size="sm">
{busy === 'prepare' ? copy.preparingLocal : copy.reviewFirst}
</Button>
</div>
<div className="justify-self-end">
<Button disabled={busy !== null} onClick={() => void prepareForReview()} size="sm">
{copy.share}
</Button>
</div>
</footer>
</div>
)}
{draftId && (
<WisdomPublicationReview
draftId={draftId}
key={draftId}
onClose={() => {
setDraftId(null)
if (resolvedEventIdsRef.current.has(event.id)) {
setEvent(null)
}
}}
onSubmitted={() => resolvedEventIdsRef.current.add(event.id)}
profile={profile}
/>
)}
</section>
)
}
@@ -0,0 +1,73 @@
// @vitest-environment jsdom
import { act, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesApi from '@/hermes'
const getWisdomInstallations = vi.fn()
const acknowledgeWisdomNotifications = vi.fn()
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<typeof HermesApi>()),
acknowledgeWisdomNotifications,
getWisdomInstallations
}))
vi.mock('@/store/notifications', () => ({ notifyError: vi.fn() }))
const notification = {
category: 'new_skill' as const,
event_id: 'event-1',
kind: 'new',
skill_id: 'skill-1',
skill_name: 'team-runbook',
source_event_ids: ['event-1'],
version: 1
}
beforeEach(() => {
vi.resetAllMocks()
getWisdomInstallations.mockResolvedValue({
installations: [],
notifications: [notification]
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('WisdomNoticeCard', () => {
it('preserves the last confirmed organization notice through a transient poll failure', async () => {
const setIntervalSpy = vi.spyOn(window, 'setInterval')
const { WisdomNoticeCard } = await import('./wisdom-notice-card')
render(<WisdomNoticeCard profile="research" />)
expect(await screen.findByText('team-runbook v1 was shared with your collective.')).toBeTruthy()
getWisdomInstallations.mockRejectedValueOnce(new Error('Gateway unavailable'))
const poll = setIntervalSpy.mock.calls.find(([, delay]) => delay === 30_000)?.[0]
expect(poll).toBeTypeOf('function')
await act(async () => {
await (poll as () => Promise<void>)()
})
expect(screen.getByText('team-runbook v1 was shared with your collective.')).toBeTruthy()
})
it('clears a notice only after a successful empty projection', async () => {
const setIntervalSpy = vi.spyOn(window, 'setInterval')
const { WisdomNoticeCard } = await import('./wisdom-notice-card')
render(<WisdomNoticeCard profile="research" />)
expect(await screen.findByText('team-runbook v1 was shared with your collective.')).toBeTruthy()
getWisdomInstallations.mockResolvedValueOnce({ installations: [], notifications: [] })
const poll = setIntervalSpy.mock.calls.find(([, delay]) => delay === 30_000)?.[0]
await act(async () => {
await (poll as () => Promise<void>)()
})
await waitFor(() => {
expect(screen.queryByText('team-runbook v1 was shared with your collective.')).toBeNull()
})
})
})
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react'
import { WisdomNotificationsCard } from '@/components/wisdom-notifications-card'
import {
acknowledgeWisdomNotifications,
getWisdomInstallations,
type ProfileScope,
type WisdomNotification
} from '@/hermes'
import { notifyError } from '@/store/notifications'
export function WisdomNoticeCard({ profile }: { profile?: ProfileScope }) {
const [events, setEvents] = useState<WisdomNotification[]>([])
useEffect(() => {
let active = true
const refresh = async () => {
try {
const result = await getWisdomInstallations(profile)
if (active) {
setEvents(result.delivery_mode === 'agent' ? [] : result.notifications)
}
} catch {
// The notice is an enhancement to the transcript. An unavailable or
// unconfigured Wisdom plane must not make ordinary chat unusable.
// Keep the last confirmed projection so a transient poll cannot make
// an actionable organization notification flicker out of the chat.
}
}
void refresh()
const timer = window.setInterval(() => void refresh(), 30_000)
return () => {
active = false
window.clearInterval(timer)
}
}, [profile])
if (events.length === 0) {
return null
}
return (
<WisdomNotificationsCard
className="mb-(--conversation-turn-gap) bg-(--ui-chat-surface-background)"
events={events}
onMarkAllRead={async () => {
try {
await acknowledgeWisdomNotifications(profile)
setEvents([])
} catch (error) {
notifyError(error, 'Could not acknowledge Wisdom notifications')
}
}}
onPlanAction={(action, event) => {
const params = new URLSearchParams({
tab: 'collective',
wisdomAction: action,
wisdomSkillId: event.skill_id
})
window.location.hash = `#/skills?${params.toString()}`
}}
/>
)
}
@@ -0,0 +1,143 @@
import type { WisdomReviewCheck, WisdomReviewCheckRow, WisdomReviewStatus } from '@/hermes'
import { AlertCircle, AlertTriangle, CheckCircle2, HelpCircle, Loader2 } from '@/lib/icons'
const PROFESSIONALISM_LABELS: Record<string, string> = {
profanity_or_abuse: 'Profanity or abusive language',
hate_or_harassment: 'Hate or harassment',
sexual_or_graphic_language: 'Sexual or graphic language',
manipulative_or_spam: 'Manipulative, deceptive, or spam-like wording'
}
function statusLabel(status: string): string {
return status.replaceAll('_', ' ').replace(/^./, value => value.toUpperCase())
}
function StatusIcon({ status }: { status: WisdomReviewStatus }) {
if (status === 'pass') {
return <CheckCircle2 aria-hidden className="size-3" />
}
if (status === 'blocked') {
return <AlertCircle aria-hidden className="size-3" />
}
if (status === 'pending' || status === 'retry' || status === 'running') {
return <Loader2 aria-hidden className="size-3 animate-spin" />
}
if (status === 'advisory') {
return <AlertTriangle aria-hidden className="size-3" />
}
return <HelpCircle aria-hidden className="size-3" />
}
function tone(status: WisdomReviewStatus): string {
if (status === 'pass') {
return 'border-emerald-500/50 text-emerald-600'
}
if (status === 'blocked') {
return 'border-destructive/60 text-destructive'
}
if (status === 'advisory') {
return 'border-amber-500/60 text-amber-500'
}
return 'border-(--ui-stroke-tertiary) text-muted-foreground'
}
export function WisdomCheckBadge({ label, value }: { label: string; value?: null | WisdomReviewCheck }) {
const status = value?.status ?? 'unavailable'
return (
<span className={cnStatus(status)}>
<StatusIcon status={status} />
{label}: {statusLabel(status)}
</span>
)
}
function cnStatus(status: WisdomReviewStatus): string {
return `inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[0.6rem] ${tone(status)}`
}
function rowLabel(row: WisdomReviewCheckRow): string {
return row.label || PROFESSIONALISM_LABELS[row.key] || statusLabel(row.key)
}
function CheckTable({ label, note, value }: { label: string; note: string; value?: null | WisdomReviewCheck }) {
const status = value?.status ?? 'unavailable'
const rows = value?.checks ?? []
return (
<section aria-label={label} className="border-t border-(--ui-stroke-tertiary) py-3 first:border-0">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-xs font-medium">{label}</h3>
<WisdomCheckBadge label="Result" value={{ status }} />
</div>
{value?.summary && <p className="mt-1 text-[0.65rem] text-muted-foreground">{value.summary}</p>}
<p className="mt-1 text-[0.6rem] text-muted-foreground">{note}</p>
{rows.length > 0 && (
<div className="mt-2 overflow-x-auto">
<table className="w-full border-collapse text-left text-[0.65rem]">
<thead className="text-muted-foreground">
<tr>
<th className="border-b border-(--ui-stroke-tertiary) py-2 pr-3 font-medium">Check</th>
<th className="border-b border-(--ui-stroke-tertiary) py-2 pr-3 font-medium">Status</th>
<th className="border-b border-(--ui-stroke-tertiary) py-2 font-medium">Details</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.key}>
<th className="border-b border-(--ui-stroke-tertiary) py-2 pr-3 font-normal">{rowLabel(row)}</th>
<td className="border-b border-(--ui-stroke-tertiary) py-2 pr-3">
<span className="inline-flex items-center gap-1">
<StatusIcon status={row.status} /> {statusLabel(row.status)}
</span>
</td>
<td className="border-b border-(--ui-stroke-tertiary) py-2 text-muted-foreground">
{row.details.length > 0
? row.details.join(' ')
: row.finding_count > 0
? `${row.finding_count} finding${row.finding_count === 1 ? '' : 's'}`
: 'No known matches detected'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
export function WisdomReviewTables({
professionalism,
security
}: {
professionalism?: null | WisdomReviewCheck
security?: null | WisdomReviewCheck
}) {
return (
<div className="mt-3 border-y border-(--ui-stroke-tertiary)">
<CheckTable
label={security?.source === 'local_preflight' ? 'Security check (local preflight)' : 'Security check'}
note={
security?.source === 'local_preflight'
? 'Local checks complete. Required Gateway checks run after you confirm upload and before publication.'
: 'Deterministic Gateway scan. A pass means no known matches were detected, not that the package is certified secure.'
}
value={security}
/>
<CheckTable
label="Professionalism check"
note="Agent-assessed and advisory. It does not block publication or installation."
value={professionalism}
/>
</div>
)
}
@@ -0,0 +1,77 @@
import { Button } from '@/components/ui/button'
import { WisdomReviewTables } from '@/components/wisdom-checks'
import type { WisdomConsentAction, WisdomConsentInteraction } from '@/hermes'
import { useI18n } from '@/i18n'
import { ChevronLeft, ChevronRight } from '@/lib/icons'
interface WisdomConsentDetailsProps {
interaction: WisdomConsentInteraction
review: WisdomConsentInteraction['inspection']
expanded: boolean
busy: boolean
active: boolean
onToggle: (open: boolean) => void
onAction: (action: WisdomConsentAction) => void
}
export function WisdomConsentDetails({
interaction,
review,
expanded,
busy,
active,
onToggle,
onAction
}: WisdomConsentDetailsProps) {
const { t } = useI18n()
const copy = t.skills.collective
return (
<details className="mt-2" onToggle={event => onToggle(event.currentTarget.open)} open={expanded}>
<summary className="cursor-pointer text-xs">{copy.reviewExact}</summary>
<WisdomReviewTables
professionalism={interaction.facts.professionalism_check}
security={interaction.facts.security_check}
/>
{interaction.facts.file_names?.map(name => (
<p className="break-words font-mono text-xs" key={name}>
{name}
</p>
))}
{review && (
<div className="mt-3 min-w-0">
<p className="whitespace-pre-wrap break-words text-xs">{review.description}</p>
<p className="mt-2 break-words font-mono text-xs">{review.path}</p>
<pre className="my-2 max-h-80 overflow-auto whitespace-pre-wrap break-words border border-(--ui-stroke-tertiary) p-2 text-xs">
{review.content}
</pre>
{active && (
<div className="flex items-center gap-2">
<Button
aria-label={copy.reviewPreviousPage}
disabled={busy || review.page === 0}
onClick={() => onAction(`inspect.${review.page - 1}`)}
size="icon"
variant="outline"
>
<ChevronLeft aria-hidden />
</Button>
<span className="text-xs tabular-nums">
{review.page + 1}/{review.page_count}
</span>
<Button
aria-label={copy.reviewNextPage}
disabled={busy || review.page + 1 >= review.page_count}
onClick={() => onAction(`inspect.${review.page + 1}`)}
size="icon"
variant="outline"
>
<ChevronRight aria-hidden />
</Button>
</div>
)}
</div>
)}
</details>
)
}
@@ -0,0 +1,296 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WisdomMediationCard } from './wisdom-mediation-card'
const { read, resolve, prepare } = vi.hoisted(() => ({ read: vi.fn(), resolve: vi.fn(), prepare: vi.fn() }))
vi.mock('@/hermes', () => ({
getWisdomMediation: read,
resolveWisdomConsent: resolve,
prepareWisdomConsentPublication: prepare
}))
vi.mock('@/components/wisdom-publication-review', () => ({
WisdomPublicationReview: ({
draftId,
consent
}: {
draftId: string
consent: { interaction_id: string; session_id: string }
}) => (
<div>
Local package {draftId} for {consent.interaction_id} in {consent.session_id}
</div>
)
}))
const interaction = {
id: 'consent',
assessment_id: 'event',
state: 'pending',
operation: 'update',
expires_at: Date.now() / 1000 + 86400,
actions: ['defer', 'inspect', 'confirm'],
facts: { slug: 'Team Runbook', version: 2, compatibility: { outcome: 'compatible' } }
}
const activity = {
mode: 'agent',
assessments: [
{
id: 'event',
owner_session: 'session',
state: 'delivered',
advice: { title: 'An updated runbook', explanation: 'It may help your current task.', relevance: 'recommend' }
}
],
interactions: [interaction]
}
describe('WisdomMediationCard', () => {
beforeEach(() => {
vi.clearAllMocks()
read.mockResolvedValue(activity)
})
afterEach(() => vi.useRealTimers())
it.each(['prerequisite', 'setup', 'verify'])(
'preserves fully reviewed %s approval with an older backend',
async phase => {
const step = {
...interaction,
operation: 'setup',
facts: {
...interaction.facts,
step: { phase, index: 0, command: phase === 'prerequisite' ? '' : 'echo reviewed' },
setup_instruction: 'Installed setup instructions',
setup_explanation: 'Why this step is proposed'
}
}
read.mockResolvedValue({ ...activity, interactions: [step] })
const { rerender } = render(<WisdomMediationCard sessionId="session" />)
await screen.findByText(/Installed setup instructions/)
expect(screen.queryByRole('button', { name: 'Update' })).toBeNull()
expect(resolve).not.toHaveBeenCalled()
if (phase !== 'prerequisite') {
expect(screen.getByText('echo reviewed').tagName).toBe('CODE')
}
resolve.mockResolvedValue({ ...step, state: 'completed', actions: ['inspect'] })
fireEvent.click(
screen.getByRole('button', { name: phase === 'prerequisite' ? 'Confirm prerequisite' : 'Run this step' })
)
await waitFor(() => expect(resolve).toHaveBeenCalledWith('consent', 'session', 'confirm', undefined))
read.mockResolvedValue({
...activity,
assessments: activity.assessments.map(entry => ({ ...entry, owner_session: 'new' })),
interactions: [{ ...step, facts: { step: step.facts.step } }]
})
rerender(<WisdomMediationCard sessionId="new" />)
await screen.findByText('Collective Wisdom is unavailable.')
expect(screen.queryByRole('button', { name: 'Run this step' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Confirm prerequisite' })).toBeNull()
}
)
it.each(['agent', 'fixed'])(
'shows requested advice and native controls in %s mode without automatic apply',
async mode => {
read.mockResolvedValue({ ...activity, mode })
render(<WisdomMediationCard sessionId="session" />)
await screen.findByText('An updated runbook')
expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([
'Not Now',
'Review first',
'Update'
])
expect(resolve).not.toHaveBeenCalled()
resolve.mockResolvedValue({ ...interaction, state: 'completed', actions: ['inspect'] })
fireEvent.click(screen.getByRole('button', { name: 'Update' }))
await waitFor(() => expect(resolve).toHaveBeenCalledWith('consent', 'session', 'confirm', undefined))
await waitFor(() => expect(screen.queryByRole('button', { name: 'Update' })).toBeNull())
}
)
it.each(['agent', 'fixed'])('keeps other surfaces passive in %s mode', async mode => {
read.mockResolvedValue({ ...activity, mode })
render(<WisdomMediationCard passive sessionId="other" />)
await screen.findByText('An updated runbook')
expect(screen.queryByRole('button', { name: 'Update' })).toBeNull()
})
it.each(['share', 'publish'])('opens full local review for %s without authorising upload', async operation => {
read.mockResolvedValue({ ...activity, interactions: [{ ...interaction, operation }] })
prepare.mockResolvedValue({ draft_id: 'local:draft' })
render(<WisdomMediationCard sessionId="session" />)
fireEvent.click(await screen.findByRole('button', { name: 'Review first' }))
expect(await screen.findByText('Local package local:draft for consent in session')).toBeTruthy()
expect(prepare).toHaveBeenCalledWith('consent', 'session', undefined)
expect(resolve).not.toHaveBeenCalled()
})
it('retains advice through a polling failure', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
render(<WisdomMediationCard sessionId="session" />)
await screen.findByText('An updated runbook')
read.mockRejectedValue(new Error('offline'))
await act(async () => {
vi.advanceTimersByTime(10_000)
})
expect(screen.getByText('An updated runbook')).toBeTruthy()
})
it('honors durable surface-local defer but retains passive access', async () => {
read.mockResolvedValue({ ...activity, interactions: [{ ...interaction, deferred_surfaces: ['local'] }] })
const { rerender } = render(<WisdomMediationCard sessionId="session" />)
await waitFor(() => expect(read).toHaveBeenCalled())
expect(screen.queryByText('An updated runbook')).toBeNull()
rerender(<WisdomMediationCard passive sessionId="session" />)
await screen.findByText('An updated runbook')
})
it.each(['agent', 'fixed'])(
'follows the exact setup control through status, recovery and recheck in %s mode',
async mode => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
const completed = {
...interaction,
state: 'completed',
setup_review: {
summary: 'Files updated',
detail: 'Setup is queued.',
command: '',
actions: [{ action: 'setup.status', label: 'Check setup', primary: false }]
}
}
read.mockResolvedValue({ ...activity, mode, interactions: [completed] })
render(<WisdomMediationCard sessionId="session" />)
expect(resolve).not.toHaveBeenCalled()
const step = {
...interaction,
id: 'setup-control',
assessment_id: 'setup-event',
operation: 'setup',
setup_review: {
summary: 'Review setup step',
detail: 'Inspect the installed guide. Only this step is authorized.',
command: 'python -c "print(123)"',
actions: [
{ action: 'defer', label: 'Not Now', primary: false },
{ action: 'confirm', label: 'Run this step', primary: true }
]
}
}
resolve.mockResolvedValue(step)
fireEvent.click(await screen.findByRole('button', { name: 'Check setup' }))
await screen.findByText(step.setup_review.detail)
expect(screen.getByText(step.setup_review.command).tagName).toBe('CODE')
expect(screen.queryByRole('button', { name: 'Update' })).toBeNull()
const unknown = {
...step,
state: 'needs_review',
setup_review: {
...step.setup_review,
summary: 'Setup outcome unknown',
actions: [
{ action: 'inspect', label: 'Check progress', primary: false },
{ action: 'setup.recover', label: 'Review interruption', primary: false }
]
}
}
resolve.mockResolvedValue(unknown)
fireEvent.click(screen.getByRole('button', { name: 'Run this step' }))
await screen.findByRole('button', { name: 'Review interruption' })
expect(resolve).toHaveBeenLastCalledWith('setup-control', 'session', 'confirm', undefined)
const recovery = {
...unknown,
setup_review: {
...unknown.setup_review,
detail: 'Check that the command and child processes have stopped. Clearing does not undo changes.',
actions: [
{ action: 'inspect', label: 'Back', primary: false },
{ action: 'setup.clear', label: 'Confirmed stopped; clear record', primary: true }
]
}
}
resolve.mockResolvedValue(recovery)
fireEvent.click(screen.getByRole('button', { name: 'Review interruption' }))
await screen.findByText(recovery.setup_review.detail)
read.mockResolvedValue({
...activity,
mode,
interactions: [completed, unknown],
assessments: [...activity.assessments, { ...activity.assessments[0], id: 'setup-event' }]
})
await act(async () => {
vi.advanceTimersByTime(10_000)
})
expect(screen.getAllByText(recovery.setup_review.detail)).toHaveLength(1)
const cleared = {
...step,
state: 'needs_review',
setup_review: {
...step.setup_review,
summary: 'Interrupted step cleared',
actions: [{ action: 'recheck', label: 'Recheck', primary: false }]
}
}
resolve.mockResolvedValue(cleared)
fireEvent.click(screen.getByRole('button', { name: 'Confirmed stopped; clear record' }))
await screen.findByRole('button', { name: 'Recheck' })
expect(resolve).toHaveBeenLastCalledWith('setup-control', 'session', 'setup.clear', undefined)
resolve.mockResolvedValue({ ...step, id: 'fresh-control' })
fireEvent.click(screen.getByRole('button', { name: 'Recheck' }))
await screen.findByRole('button', { name: 'Run this step' })
expect(resolve).toHaveBeenLastCalledWith('setup-control', 'session', 'recheck', undefined)
expect(resolve).toHaveBeenCalledTimes(5)
}
)
it.each(['session', 'other'])(
'keeps setup passive and drops an old action response after session changes from %s',
async sessionId => {
const step = {
...interaction,
operation: 'setup',
setup_review: {
summary: 'Review setup step',
detail: 'Exact installed instructions',
command: 'echo reviewed',
actions: [{ action: 'confirm', label: 'Run this step', primary: true }]
}
}
read.mockResolvedValue({ ...activity, interactions: [step] })
const { rerender } = render(<WisdomMediationCard passive sessionId={sessionId} />)
await screen.findByText('Exact installed instructions')
expect(screen.queryByRole('button')).toBeNull()
rerender(<WisdomMediationCard sessionId="session" />)
let finish!: (value: unknown) => void
resolve.mockReturnValue(
new Promise(done => {
finish = done
})
)
fireEvent.click(await screen.findByRole('button', { name: 'Run this step' }))
read.mockResolvedValue({ mode: 'agent', assessments: [], interactions: [] })
rerender(<WisdomMediationCard sessionId="new-session" />)
await act(async () => {
finish(step)
})
expect(screen.queryByText('Exact installed instructions')).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
}
)
})
@@ -0,0 +1,301 @@
import { useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { WisdomConsentDetails } from '@/components/wisdom-consent-details'
import { WisdomPublicationReview } from '@/components/wisdom-publication-review'
import { legacySetupReview, WisdomSetupReview } from '@/components/wisdom-setup-review'
import {
getWisdomMediation,
prepareWisdomConsentPublication,
type ProfileScope,
resolveWisdomConsent,
type WisdomConsentAction,
type WisdomConsentInteraction,
type WisdomMediationActivity
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
export function WisdomMediationCard({
profile,
sessionId,
passive = false
}: {
profile?: ProfileScope
sessionId?: string
passive?: boolean
}) {
const { t } = useI18n()
const copy = t.skills.collective
const [activity, setActivity] = useState<WisdomMediationActivity | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<string | null>(null)
const [publication, setPublication] = useState<{ draft_id: string; interaction_id: string } | null>(null)
const [reviews, setReviews] = useState<Record<string, WisdomConsentInteraction['inspection']>>({})
const [deferred, setDeferred] = useState<Set<string>>(() => new Set())
const [opened, setOpened] = useState<{ entryId: string; interaction: WisdomConsentInteraction } | null>(null)
const revision = useRef(0)
const acting = useRef(false)
useEffect(() => {
let active = true
setActivity(null)
setDeferred(new Set())
setBusy(null)
setError(null)
setReviews({})
setPublication(null)
setOpened(null)
const refresh = async () => {
if (acting.current) {
return
}
const request = ++revision.current
try {
const next = await getWisdomMediation(profile)
if (active && request === revision.current) {
setActivity(next)
setOpened(current => {
const fresh = next.interactions.find(item => item.id === current?.interaction.id)
if (!current || !fresh) {
return null
}
// Keep explicit status/recovery review open, but never retain an old approval state.
return fresh.state !== current.interaction.state || fresh.expires_at !== current.interaction.expires_at
? { ...current, interaction: fresh }
: current
})
}
} catch {
// Keep the last known valid advice through transient outages.
}
}
void refresh()
const timer = window.setInterval(() => void refresh(), 10_000)
return () => {
active = false
revision.current++
window.clearInterval(timer)
}
}, [profile, sessionId])
const act = async (interaction: WisdomConsentInteraction, action: WisdomConsentAction) => {
if (!sessionId || acting.current) {
return
}
acting.current = true
const request = ++revision.current
setBusy(interaction.id)
setError(null)
try {
if (['share', 'publish'].includes(interaction.operation) && (action === 'inspect' || action === 'confirm')) {
const prepared = await prepareWisdomConsentPublication(interaction.id, sessionId, profile)
if (request === revision.current) {
setPublication({ ...prepared, interaction_id: interaction.id })
}
return
}
const result = await resolveWisdomConsent(interaction.id, sessionId, action, profile)
if (request !== revision.current) {
return
}
setActivity(current =>
current
? {
...current,
interactions: current.interactions.map(item => (item.id === result.id ? result : item))
}
: current
)
if (result.setup_review) {
setOpened(current => ({
entryId: current?.interaction.id === interaction.id ? current.entryId : interaction.assessment_id,
interaction: result
}))
}
if (action.startsWith('inspect')) {
setExpanded(result.id)
}
if (result.inspection) {
setReviews(current => ({ ...current, [result.id]: result.inspection }))
}
if (action === 'defer') {
setDeferred(current => new Set([...current, result.id]))
}
} catch {
if (request === revision.current) {
setError(copy.unavailable)
}
} finally {
acting.current = false
if (request === revision.current) {
setBusy(null)
}
}
}
if (!activity) {
return null
}
const entries = activity.assessments.filter(item => item.advice && (passive || item.owner_session === sessionId))
if (!entries.length) {
return null
}
return (
<section aria-label={copy.notifications} className="my-3 min-w-0 border-y border-(--ui-stroke-tertiary) py-3">
<h2 className="text-sm font-semibold">{copy.title}</h2>
{publication && sessionId && (
<WisdomPublicationReview
consent={{ interaction_id: publication.interaction_id, session_id: sessionId }}
draftId={publication.draft_id}
key={publication.draft_id}
onClose={() => setPublication(null)}
profile={profile}
/>
)}
{error && (
<p className="mt-2 text-sm text-destructive" role="alert">
{error}
</p>
)}
{entries.map(entry => {
const interaction =
opened?.entryId === entry.id
? opened.interaction
: activity.interactions.find(item => item.assessment_id === entry.id)
if (opened && opened.entryId !== entry.id && interaction?.id === opened.interaction.id) {
return null
}
if (
!passive &&
interaction &&
(deferred.has(interaction.id) || interaction.deferred_surfaces?.includes('local'))
) {
return null
}
const own = !passive && !!sessionId && entry.owner_session === sessionId
const pending = interaction?.state === 'pending' && interaction.expires_at * 1000 > Date.now()
const review = interaction ? reviews[interaction.id] : undefined
const setupReview = interaction ? (interaction.setup_review ?? legacySetupReview(interaction, copy)) : undefined
if (publication?.interaction_id === interaction?.id) {
return null
}
return (
<article className="min-w-0 border-t border-(--ui-stroke-tertiary) py-3 first:border-0" key={entry.id}>
<h3 className="break-words text-sm font-medium">{entry.advice?.title}</h3>
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-(--ui-text-secondary)">
{entry.advice?.explanation}
</p>
{interaction && (
<>
<p className="mt-2 text-xs font-medium">
{interaction.facts.editorial_name || interaction.facts.slug}
{interaction.facts.version ? ` · v${interaction.facts.version}` : ''}
</p>
{interaction.facts.compatibility && (
<p className="mt-1 text-xs">{interaction.facts.compatibility.outcome.replaceAll('_', ' ')}</p>
)}
{interaction.facts.modified && <p className="text-xs text-destructive">{copy.unsavedChanges}</p>}
{interaction.facts.sensitive_expansion?.map((warning, i) => (
<p className="text-xs text-destructive" key={i}>
{warning}
</p>
))}
{interaction.operation === 'share' && (
<p className="mt-2 text-xs text-(--ui-text-secondary)">{copy.sharePreparationNotice}</p>
)}
<WisdomConsentDetails
active={own}
busy={busy !== null}
expanded={expanded === interaction.id}
interaction={interaction}
onAction={action => void act(interaction, action)}
onToggle={open => {
setExpanded(current => (open ? interaction.id : current === interaction.id ? null : current))
}}
review={review}
/>
{setupReview ? (
<WisdomSetupReview
active={own}
busy={busy !== null}
onAction={action => void act(interaction, action)}
review={setupReview}
/>
) : own && pending && interaction.operation !== 'setup' ? (
<div className="mt-3 grid grid-cols-3 items-start gap-2 [&>button]:h-auto [&>button]:min-h-8 [&>button]:min-w-0 [&>button]:whitespace-normal [&>button]:break-words">
<Button
disabled={busy !== null}
onClick={() => void act(interaction, 'defer')}
size="sm"
variant="outline"
>
{copy.notNow}
</Button>
<Button
disabled={busy !== null}
onClick={() => void act(interaction, 'inspect')}
size="sm"
variant="outline"
>
{copy.reviewFirst}
</Button>
{interaction.actions.includes('confirm') && (
<Button disabled={busy !== null} onClick={() => void act(interaction, 'confirm')} size="sm">
{busy === interaction.id && <Loader2 aria-hidden className="size-3 animate-spin" />}
{interaction.operation === 'share'
? copy.share
: interaction.operation === 'publish'
? copy.approve
: interaction.operation === 'install'
? copy.install
: t.common.update}
</Button>
)}
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">
{interaction.result?.packaging_state === 'queued'
? copy.preparingLocal
: interaction.result?.packaging_state === 'failed'
? copy.unavailable
: copy.draftState(interaction.state)}
</p>
)}
</>
)}
</article>
)
})}
</section>
)
}
@@ -0,0 +1,107 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
// @vitest-environment jsdom
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WisdomNotificationSettings } from './wisdom-notification-settings'
const { prepare, read, choose } = vi.hoisted(() => ({ prepare: vi.fn(), read: vi.fn(), choose: vi.fn() }))
vi.mock('@/hermes', () => ({ prepareWisdomMute: prepare, getWisdomMute: read, chooseWisdomMute: choose }))
vi.mock('@/api/client', () => ({
capabilityScoped: (profile: string) => ({ profile, connectionId: 'local' }),
profileScopeKey: (scope: object) => JSON.stringify(scope)
}))
const control = {
id: 'a'.repeat(32),
expires_at: Date.now() / 1000 + 600,
organization_id: 'org',
sync: null,
mute: { org_id: 'org', revision: 3, duration: null, muted: false, forever: false, muted_until: null }
}
const snapshot = { organization_id: 'org', gateway_available: true, mute: control.mute, sync: null }
beforeEach(() => {
vi.clearAllMocks()
prepare.mockResolvedValue(control)
read.mockResolvedValue(snapshot)
choose.mockResolvedValue(snapshot)
})
afterEach(cleanup)
async function open() {
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }))
await screen.findByText('Notifications on', { selector: 'p' })
}
describe('Wisdom notification settings', () => {
it('loads only on request and changes only after Save', async () => {
render(<WisdomNotificationSettings profile="demo" />)
expect(prepare).not.toHaveBeenCalled()
await open()
fireEvent.change(screen.getByRole('combobox'), { target: { value: '1_week' } })
expect(choose).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(choose).toHaveBeenCalledOnce())
expect(choose).toHaveBeenCalledWith(control.id, '1_week', { profile: 'demo', connectionId: 'local' })
})
it('keeps the original choice when a reply is lost', async () => {
choose.mockRejectedValueOnce(new Error('timeout'))
render(<WisdomNotificationSettings profile="demo" />)
await open()
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'forever' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await screen.findByRole('button', { name: 'Retry' })
expect((screen.getByRole('combobox') as HTMLSelectElement).disabled).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(choose).toHaveBeenCalledTimes(2))
expect(choose.mock.calls[0]).toEqual(choose.mock.calls[1])
})
it('does not hide shared state on a transient poll error', async () => {
render(<WisdomNotificationSettings profile="demo" />)
await open()
read.mockRejectedValue(new Error('offline'))
fireEvent(window, new Event('focus'))
await screen.findByRole('alert')
expect(screen.getByText('Notifications on', { selector: 'p' })).toBeTruthy()
})
it('remains usable under StrictMode and separates pending from shared state', async () => {
prepare.mockResolvedValue({
...control,
sync: {
preference_sync: 'pending',
requested_duration: 'forever',
mutation_id: 'queued'
}
})
render(
<StrictMode>
<WisdomNotificationSettings profile="demo" />
</StrictMode>
)
await open()
expect(screen.getByText('Your choice is saved locally and waiting to sync.')).toBeTruthy()
expect(choose).not.toHaveBeenCalled()
expect(screen.getByText('Notifications on', { selector: 'p' })).toBeTruthy()
})
it('drops responses from the previous profile', async () => {
let finish!: (value: typeof control) => void
prepare.mockReturnValueOnce(
new Promise(resolve => {
finish = resolve
})
)
const { rerender } = render(<WisdomNotificationSettings profile="first" />)
fireEvent.click(screen.getByRole('button', { name: 'Notification settings' }))
await waitFor(() => expect(prepare).toHaveBeenCalledOnce())
rerender(<WisdomNotificationSettings profile="second" />)
await act(async () => finish(control))
expect(screen.queryByRole('combobox')).toBeNull()
await open()
expect(prepare).toHaveBeenLastCalledWith({ profile: 'second', connectionId: 'local' })
})
})
@@ -0,0 +1,168 @@
import { createWisdomMuteController, initialWisdomMuteState, type WisdomMuteDuration } from '@hermes/shared'
import { useEffect, useRef, useState } from 'react'
import { capabilityScoped, profileScopeKey } from '@/api/client'
import { Button } from '@/components/ui/button'
import { chooseWisdomMute, getWisdomMute, prepareWisdomMute, type ProfileScope } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2, RefreshCw, Volume2, VolumeX } from '@/lib/icons'
export function WisdomNotificationSettings({ profile }: { profile?: ProfileScope }) {
const scope = capabilityScoped(profile)
return <Settings key={profileScopeKey(scope)} profile={scope} />
}
function Settings({ profile }: { profile?: ProfileScope }) {
const { t, locale } = useI18n()
const copy = t.skills.collective.notificationPreferences
const [open, setOpen] = useState(false)
const [state, setState] = useState(initialWisdomMuteState)
const [scope] = useState(profile)
const controller = useRef<ReturnType<typeof createWisdomMuteController> | null>(null)
// eslint-disable-next-line no-restricted-syntax -- Effect-owned imperative controller, not a mirrored reactive value.
useEffect(() => {
if (!open) {
return
}
const next = createWisdomMuteController({
prepare: () => prepareWisdomMute(scope),
read: () => getWisdomMute(scope),
choose: (id, duration) => chooseWisdomMute(id, duration, scope),
changed: setState
})
controller.current = next
void next.refresh()
const poll = () => {
if (document.visibilityState !== 'hidden') {
void next.poll()
}
}
const timer = window.setInterval(poll, 15_000)
window.addEventListener('focus', poll)
return () => {
next.dispose()
controller.current = null
window.clearInterval(timer)
window.removeEventListener('focus', poll)
}
}, [open, scope])
const muted = state.snapshot?.mute?.muted
const until = state.snapshot?.mute?.muted_until
const sync = state.snapshot?.sync?.preference_sync
const notice =
sync === 'pending' || sync === 'syncing'
? copy.pending
: sync === 'failed'
? copy.failed
: sync === 'conflict'
? copy.conflict
: sync === 'expired'
? copy.expired
: null
const Icon = muted ? VolumeX : Volume2
const choice = state.choice === undefined ? '' : (state.choice ?? 'off')
return (
<section className="border-b border-(--ui-stroke-tertiary) min-w-0 py-2">
<button
aria-expanded={open}
className="flex max-w-full items-center gap-2 text-sm"
onClick={() => setOpen(value => !value)}
type="button"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
<span className="break-words text-start">{copy.title}</span>
</button>
{open && (
<div className="mt-3 min-w-0 space-y-3">
<p className="text-muted-foreground text-xs">{copy.scope}</p>
<p className="break-words text-sm" role="status">
{state.snapshot?.mute
? muted
? copy.muted
: copy.on
: state.busy
? t.common.loading
: t.skills.collective.unavailable}
{muted && until && (
<>
{' '}
· <time dateTime={until}>{new Date(until).toLocaleString(locale)}</time>
</>
)}
{muted && state.snapshot?.mute?.forever && <> · {copy.forever}</>}
</p>
{notice && (
<p className="text-xs" role="status">
{notice}
</p>
)}
{state.error && (
<p className="text-destructive text-xs" role="alert">
{t.skills.collective.unavailable}
</p>
)}
<form
className="flex min-w-0 flex-wrap items-center gap-2"
onSubmit={event => {
event.preventDefault()
void controller.current?.apply()
}}
>
<select
aria-label={copy.title}
className="border-(--ui-stroke-tertiary) bg-background h-9 min-w-0 max-w-full flex-1 rounded border px-2 text-sm"
disabled={state.busy || !state.control || state.retryingChoice}
onChange={event =>
controller.current?.select(
event.target.value === 'off' ? null : (event.target.value as WisdomMuteDuration)
)
}
value={choice}
>
<option disabled value="">
{copy.title}
</option>
<option value="off">{copy.on}</option>
<option value="1_day">{copy.day}</option>
<option value="1_week">{copy.week}</option>
<option value="30_days">{copy.month}</option>
<option value="forever">{copy.forever}</option>
</select>
<Button
aria-label={t.common.refresh}
className="h-9 shrink-0"
disabled={state.busy}
onClick={() => void controller.current?.refresh()}
size="sm"
title={t.common.refresh}
type="button"
variant="outline"
>
<RefreshCw aria-hidden="true" className="size-4" />
</Button>
<Button
className="h-9 shrink-0"
disabled={state.busy || (!state.retryingChoice && (state.choice === undefined || !state.control))}
size="sm"
type="submit"
>
{state.busy && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
{state.retryingChoice ? t.common.retry : t.common.save}
</Button>
</form>
</div>
)}
</section>
)
}
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { WisdomNotificationsCard } from './wisdom-notifications-card'
const openExternalLink = vi.fn()
vi.mock('@/lib/external-link', () => ({
openExternalLink: (href: string) => openExternalLink(href)
}))
describe('WisdomNotificationsCard', () => {
it('renders skill names and versions with update and Portal actions', async () => {
const markAllRead = vi.fn().mockResolvedValue(undefined)
const planAction = vi.fn().mockResolvedValue(undefined)
render(
<WisdomNotificationsCard
events={[
{
category: 'update_available',
event_id: 'event-1',
kind: 'updated',
occurred_at: '2026-08-28T00:00:00Z',
portal_url: 'https://portal.example/orgs/team/wisdom/skills/skill-1?version=3',
skill_id: 'skill-1',
skill_name: 'incident-handoff',
source_event_ids: ['event-1'],
version: 3
}
]}
onMarkAllRead={markAllRead}
onPlanAction={planAction}
/>
)
expect(screen.getByText('incident-handoff v3 is available to update.')).toBeTruthy()
expect(screen.queryByText(/skill-1/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Review update' }))
expect(planAction).toHaveBeenCalledWith('update', expect.objectContaining({ skill_id: 'skill-1', version: 3 }))
fireEvent.click(screen.getByRole('button', { name: 'View skill' }))
expect(openExternalLink).toHaveBeenCalledWith('https://portal.example/orgs/team/wisdom/skills/skill-1?version=3')
})
it('offers install for newly shared skills', () => {
const planAction = vi.fn()
render(
<WisdomNotificationsCard
events={[
{
category: 'new_skill',
editorial_description: 'Coordinate incidents with a repeatable team workflow.',
editorial_name: 'Team Incident Runbook',
event_id: 'event-new',
kind: 'new',
skill_id: 'skill-new',
skill_name: 'team-runbook',
source_event_ids: ['event-new'],
version: 1
}
]}
onMarkAllRead={vi.fn()}
onPlanAction={planAction}
/>
)
expect(screen.getByText('Team Incident Runbook v1 was shared with your collective.')).toBeTruthy()
expect(screen.getByText('Coordinate incidents with a repeatable team workflow.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Install…' }))
expect(planAction).toHaveBeenCalledWith('install', expect.objectContaining({ skill_id: 'skill-new' }))
})
it('offers install with update copy for a revised uninstalled shared skill', () => {
const planAction = vi.fn()
render(
<WisdomNotificationsCard
events={[
{
category: 'new_skill',
event_id: 'event-shared-update',
kind: 'updated',
skill_id: 'skill-shared',
skill_name: 'team-runbook',
source_event_ids: ['event-shared-update'],
version: 2
}
]}
onMarkAllRead={vi.fn()}
onPlanAction={planAction}
/>
)
expect(screen.getByText('team-runbook v2 is available to update.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Install…' }))
expect(planAction).toHaveBeenCalledWith('install', expect.objectContaining({ skill_id: 'skill-shared' }))
})
})
@@ -0,0 +1,148 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import type { WisdomNotification } from '@/hermes'
import { useI18n } from '@/i18n'
import type { WisdomTranslations } from '@/i18n/types'
import { openExternalLink } from '@/lib/external-link'
import { cn } from '@/lib/utils'
function notificationText(event: WisdomNotification, copy: WisdomTranslations): string {
const skill = event.editorial_name?.trim() || event.skill_name
const version = event.version ? `v${event.version}` : undefined
const skillVersion = version ? `${skill} ${version}` : skill
if (event.category === 'publication_decision') {
if (event.state === 'published' || event.state === 'approved') {
return copy.decisionPublished(skillVersion)
}
if (event.state === 'changes_requested') {
const note = event.moderation_note?.trim()
return `${copy.decisionChanges(skillVersion)}${note ? ` ${note}` : ''}`
}
if (event.state === 'declined' || event.state === 'rejected') {
return copy.decisionDeclined(skillVersion)
}
return copy.decisionChanged(skillVersion, copy.draftState(event.state || 'updated'))
}
if (event.category === 'installed') {
return copy.installedNotice(skill, version)
}
if (event.category === 'updated') {
return copy.updatedNotice(skill, version)
}
if (event.category === 'update_available') {
return copy.updateNotice(skill, version)
}
if (event.category === 'new_skill' && event.kind === 'updated') {
return copy.updateNotice(skill, version)
}
if (event.category === 'new_skill') {
return copy.newSkillNotice(skillVersion)
}
return copy.unavailableNotice(skillVersion)
}
export function WisdomNotificationsCard({
className,
events,
onMarkAllRead,
onPlanAction
}: {
className?: string
events: WisdomNotification[]
onMarkAllRead: () => Promise<void>
onPlanAction?: (action: 'install' | 'update', event: WisdomNotification) => Promise<void> | void
}) {
const { t } = useI18n()
const copy = t.skills.collective
const [clearing, setClearing] = useState(false)
const [planning, setPlanning] = useState<null | string>(null)
if (events.length === 0) {
return null
}
return (
<section
aria-label={copy.notifications}
className={cn(
'rounded-[4px] border border-(--ui-stroke-secondary) bg-(--ui-widget-surface-background) px-3 py-2.5',
className
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xs font-semibold">{copy.notifications}</h2>
<p className="mt-0.5 text-[0.65rem] text-muted-foreground">{copy.activityReady(events.length)}</p>
</div>
<Button
disabled={clearing}
onClick={async () => {
setClearing(true)
try {
await onMarkAllRead()
} finally {
setClearing(false)
}
}}
size="inline"
variant="text"
>
{copy.markSeen}
</Button>
</div>
<ul className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
{events.slice(0, 8).map(event => (
<li
className="flex min-w-0 items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
key={event.event_id}
>
<p className="min-w-0 text-[0.68rem] leading-4 text-(--ui-text-secondary)">
{notificationText(event, copy)}
{event.editorial_description?.trim() ? (
<span className="mt-0.5 block text-muted-foreground">{event.editorial_description.trim()}</span>
) : null}
</p>
<div className="flex shrink-0 items-center gap-3">
{event.portal_url ? (
<Button onClick={() => openExternalLink(event.portal_url || '')} size="inline" variant="text">
{copy.viewSkill}
</Button>
) : null}
{onPlanAction && (event.category === 'new_skill' || event.category === 'update_available') ? (
<Button
disabled={planning === event.event_id}
onClick={async () => {
setPlanning(event.event_id)
try {
await onPlanAction(event.category === 'new_skill' ? 'install' : 'update', event)
} finally {
setPlanning(null)
}
}}
size="inline"
variant="textStrong"
>
{event.category === 'new_skill' ? copy.install : copy.reviewUpdate}
</Button>
) : null}
</div>
</li>
))}
</ul>
</section>
)
}
@@ -0,0 +1,302 @@
import { useEffect, useRef, useState } from 'react'
import { WisdomFileEditor } from '@/app/skills/wisdom-file-editor'
import { wisdomManifestValidationError } from '@/app/skills/wisdom-manifest'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { WisdomReviewTables } from '@/components/wisdom-checks'
import {
decideWisdomDraft,
type ProfileScope,
type WisdomPublicationReview as Review,
reviewWisdomPublication,
reviseWisdomDraft,
saveWisdomPreparedDraft,
submitWisdomPublication,
type WisdomPublicationResult
} from '@/hermes'
import { useI18n } from '@/i18n'
export function WisdomPublicationReview({
draftId,
profile,
onClose,
onSubmitted,
consent,
allowDecline = false
}: {
draftId: string
profile?: ProfileScope
onClose: () => void
onSubmitted?: (result: WisdomPublicationResult) => void
consent?: { interaction_id: string; session_id: string }
allowDecline?: boolean
}) {
const { t } = useI18n()
const copy = t.skills.collective
const [review, setReview] = useState<Review | null>(null)
const [description, setDescription] = useState('')
const [files, setFiles] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(true)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<WisdomPublicationResult | null>(null)
const acting = useRef(false)
const generation = useRef(0)
const apply = (next: Review) => {
setReview(next)
const state = next.draft.state
setResult(
next.portal_url && (state === 'published' || state === 'pending_moderation')
? { draft_id: next.draft.id, publication_state: state, portal_url: next.portal_url }
: null
)
setDescription(next.draft.authorDescription || '')
setFiles(Object.fromEntries(next.files.map(file => [file.path, file.content_utf8])))
}
useEffect(() => {
const current = ++generation.current
setReview(null)
setResult(null)
setBusy(true)
setError(null)
void reviewWisdomPublication(draftId, profile)
.then(next => {
if (generation.current === current) {
apply(next)
}
})
.catch(reason => {
if (generation.current === current) {
setError(String(reason instanceof Error ? reason.message : reason))
}
})
.finally(() => {
if (generation.current === current) {
setBusy(false)
}
})
return () => {
generation.current++
}
}, [draftId, profile])
const reload = async (id: string) => {
const current = generation.current
const next = await reviewWisdomPublication(id, profile)
if (generation.current === current) {
apply(next)
}
}
const local = review?.draft.state === 'prepared'
const editable = !!review && ['prepared', 'ready', 'changes_requested'].includes(review.draft.state)
const dirty =
!!review &&
(description !== (review.draft.authorDescription || '') ||
review.files.some(file => files[file.path] !== file.content_utf8))
const manifestError = review ? wisdomManifestValidationError(files['skill.manifest.json'] || '') : null
const canSubmit = !!review && ['prepared', 'ready', 'owner_approved', 'publishing'].includes(review.draft.state)
const blocked = review?.draft.security_check?.status === 'blocked'
const pendingChecks = [review?.draft.security_check, review?.draft.professionalism_check].some(
check => check && ['pending', 'running', 'retry'].includes(check.status)
)
const run = async (operation: () => Promise<void>) => {
if (acting.current) {
return
}
acting.current = true
const current = generation.current
setBusy(true)
setError(null)
try {
await operation()
} catch (reason) {
if (generation.current === current) {
setError(String(reason instanceof Error ? reason.message : reason))
}
} finally {
acting.current = false
if (generation.current === current) {
setBusy(false)
}
}
}
const save = () =>
run(async () => {
if (!review || !dirty || manifestError || !description.trim()) {
return
}
const edited = review.files.map(file => ({ path: file.path, content_utf8: files[file.path] }))
const current = generation.current
const saved = local
? await saveWisdomPreparedDraft(review.draft.id, description, edited, profile)
: await reviseWisdomDraft(review.draft.id, description, edited, review.hashes, profile)
const id = 'local_draft_id' in saved ? saved.local_draft_id : saved.draft.id
const next = await reviewWisdomPublication(id, profile)
if (generation.current === current) {
apply(next)
}
})
const submit = () =>
run(async () => {
if (!review || dirty || !canSubmit || blocked || pendingChecks || manifestError) {
return
}
const current = generation.current
const submitted = await submitWisdomPublication(review, profile, consent)
if (generation.current === current) {
setResult(submitted)
onSubmitted?.(submitted)
}
})
return (
<section aria-label={copy.ownerReviewExact} className="min-w-0 space-y-4 p-4">
<header className="flex items-start justify-between gap-3">
<h3 className="break-words text-sm font-semibold">{review?.draft.slug || copy.prepareTitle}</h3>
<Button disabled={busy} onClick={onClose} size="sm" variant="outline">
{copy.close}
</Button>
</header>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{result ? (
<div role="status">
<p className="text-sm font-medium">{copy.draftState(result.publication_state)}</p>
<a
className="mt-2 inline-block text-sm hover:underline"
href={result.portal_url}
rel="noreferrer"
target="_blank"
>
{copy.viewInPortal}
</a>
{allowDecline && result.publication_state === 'pending_moderation' && (
<Button disabled={busy} onClick={() => void run(async () => {
await decideWisdomDraft(result.draft_id, 'decline', profile)
onClose()
})} size="sm" variant="outline">{copy.decline}</Button>
)}
</div>
) : review ? (
<>
<p className="text-xs text-muted-foreground">
{review.publication_mode === 'open' ? copy.publishLocalNotice : copy.submitLocalNotice}
</p>
<WisdomReviewTables
professionalism={review.draft.professionalism_check}
security={review.draft.security_check}
/>
<label className="block text-xs">
{copy.ownerDescription}
<Textarea
className="mt-2 min-h-24"
disabled={busy || !editable}
maxLength={4096}
onChange={event => setDescription(event.target.value)}
value={description}
/>
</label>
{review.files.map(file => (
<WisdomFileEditor
disabled={busy || !editable}
file={file}
key={file.path}
onChange={value => setFiles(current => ({ ...current, [file.path]: value }))}
reviewSource={local ? 'local' : 'server'}
value={files[file.path] ?? file.content_utf8}
/>
))}
{dirty && (
<p className="text-xs text-amber-600" role="status">
{copy.unsavedChanges}
</p>
)}
{manifestError && (
<p className="text-xs text-destructive" role="alert">
{manifestError}
</p>
)}
<div className="flex flex-wrap justify-end gap-2">
{allowDecline &&
!local &&
['ready', 'changes_requested', 'pending_moderation'].includes(review.draft.state) && (
<Button
disabled={busy}
onClick={() =>
void run(async () => {
await decideWisdomDraft(review.draft.id, 'decline', profile)
onClose()
})
}
size="sm"
variant="outline"
>
{copy.decline}
</Button>
)}
{dirty && (
<Button disabled={busy} onClick={() => apply(review)} size="sm" variant="outline">
{copy.resetChanges}
</Button>
)}
<Button disabled={busy} onClick={() => void run(() => reload(review.draft.id))} size="sm" variant="outline">
{copy.reloadReview}
</Button>
{dirty && (
<Button disabled={busy || !!manifestError || !description.trim()} onClick={() => void save()} size="sm">
{copy.saveAndRescan}
</Button>
)}
<Button
disabled={
busy || dirty || !canSubmit || blocked || pendingChecks || !!manifestError || !description.trim()
}
onClick={() => void submit()}
size="sm"
>
{busy
? copy.submitting
: review.publication_mode === 'open'
? copy.publishToTeam
: copy.submitForApproval}
</Button>
</div>
</>
) : (
!busy && (
<Button onClick={() => void run(() => reload(draftId))} size="sm">
{copy.reloadReview}
</Button>
)
)}
{busy && (
<p className="text-xs text-muted-foreground" role="status">
{copy.loading}
</p>
)}
</section>
)
}
@@ -0,0 +1,76 @@
import { Button } from '@/components/ui/button'
import type { WisdomConsentAction, WisdomConsentInteraction } from '@/hermes'
import type { WisdomTranslations } from '@/i18n/types'
export function legacySetupReview(
interaction: WisdomConsentInteraction,
copy: WisdomTranslations
): WisdomConsentInteraction['setup_review'] {
if (interaction.operation !== 'setup') {
return undefined
}
const { step, setup_instruction: instruction, setup_explanation: explanation } = interaction.facts
const reviewable = !!instruction && !!step && (step.phase === 'prerequisite' || !!step.command)
const pending = interaction.state === 'pending' && interaction.expires_at * 1000 > Date.now()
const labels = {
defer: copy.notNow,
inspect: copy.reviewFirst,
confirm: step?.phase === 'prerequisite' ? copy.confirmSetupPrerequisite : copy.runSetupStep
}
// Older APIs accept these three actions only. Never infer recovery support or approve an incomplete review.
return {
summary: copy.reviewExact,
detail: reviewable
? [explanation, instruction, pending ? copy.setupStepApprovalNotice : ''].filter(Boolean).join('\n\n')
: copy.unavailable,
command: step?.command || '',
command_label: copy.setupCommand,
actions: interaction.actions
.filter(action => action === 'inspect' || (pending && reviewable && !interaction.deferred))
.map(action => ({ action, label: labels[action], primary: action === 'confirm' }))
}
}
interface WisdomSetupReviewProps {
review: NonNullable<WisdomConsentInteraction['setup_review']>
active: boolean
busy: boolean
onAction: (action: WisdomConsentAction) => void
}
export function WisdomSetupReview({ review, active, busy, onAction }: WisdomSetupReviewProps) {
return (
<section aria-label={review.summary} className="mt-3 min-w-0">
<h4 className="text-sm font-medium" role="status">
{review.summary}
</h4>
<p className="mt-2 whitespace-pre-wrap break-words text-sm text-(--ui-text-secondary)">{review.detail}</p>
{review.command && (
<div className="mt-3 min-w-0">
<p className="text-xs font-medium">{review.command_label}</p>
<pre className="mt-1 overflow-auto whitespace-pre-wrap break-all border border-(--ui-stroke-tertiary) p-2 text-xs">
<code>{review.command}</code>
</pre>
</div>
)}
{active && (
<div className="mt-3 flex flex-wrap gap-2 [&>button]:min-w-0 [&>button]:whitespace-normal [&>button]:break-words">
{review.actions.map(action => (
<Button
disabled={busy}
key={action.action}
onClick={() => onAction(action.action)}
size="sm"
variant={action.primary ? 'default' : 'outline'}
>
{action.label}
</Button>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { WisdomSyncStatus } from './wisdom-sync-status'
const { read, retry } = vi.hoisted(() => ({ read: vi.fn(), retry: vi.fn() }))
vi.mock('@/hermes', () => ({ getWisdomSync: read, retryWisdomSync: retry }))
vi.mock('@/api/client', () => ({ capabilityScoped: (p: string) => p, profileScopeKey: (p: string) => p }))
vi.mock('@/i18n', () => ({
useI18n: () => ({ t: { common: { refresh: 'Refresh', loading: 'Loading' }, skills: { collective: {} } } })
}))
const counts = { pending: 0, syncing: 0, retryable: 0, conflict: 0, uncertain: 0, waiting_for_receipt: 0 }
const snapshot = { delivery: counts, operation: { ...counts, retryable: 1 }, can_retry: true }
beforeEach(() => {
vi.clearAllMocks()
read.mockResolvedValue(snapshot)
retry.mockResolvedValue({ delivery: counts, operation: counts, can_retry: false })
})
afterEach(cleanup)
it('opens read-only and only retries on the explicit control', async () => {
render(<WisdomSyncStatus profile="research" />)
expect(read).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Receipt and report sync' }))
await screen.findByText('Retry available: 1')
expect(read).toHaveBeenCalledWith('research')
expect(retry).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Retry sync' }))
await waitFor(() => expect(screen.getAllByText('Up to date')).toHaveLength(2))
expect(retry).toHaveBeenCalledExactlyOnceWith('research')
expect(screen.getByRole('button', { name: 'Retry sync' }).hasAttribute('disabled')).toBe(true)
})
it('does not expose a retired profiles in-flight result', async () => {
let finish!: (v: typeof snapshot) => void
read.mockReturnValueOnce(
new Promise(resolve => {
finish = resolve
})
)
const view = render(<WisdomSyncStatus profile="research" />)
fireEvent.click(screen.getByRole('button', { name: 'Receipt and report sync' }))
view.rerender(<WisdomSyncStatus profile="other" />)
await act(async () => {
finish(snapshot)
})
expect(screen.queryByText('Retry available: 1')).toBeNull()
expect(screen.getByRole('button', { name: 'Receipt and report sync' }).getAttribute('aria-expanded')).toBe('false')
})
it('shows unavailable without raw errors or an enabled retry', async () => {
read.mockRejectedValueOnce(new Error('secret transport payload'))
render(<WisdomSyncStatus />)
fireEvent.click(screen.getByRole('button', { name: 'Receipt and report sync' }))
expect((await screen.findByRole('alert')).textContent).toContain('Sync status could not be confirmed.')
expect(screen.queryByText('secret transport payload')).toBeNull()
expect(screen.getByRole('button', { name: 'Retry sync' }).hasAttribute('disabled')).toBe(true)
})
@@ -0,0 +1,122 @@
import { createWisdomSyncController, initialWisdomSyncView, wisdomSyncCopy, type WisdomSyncState } from '@hermes/shared'
import { useEffect, useRef, useState } from 'react'
import { capabilityScoped, profileScopeKey } from '@/api/client'
import { Button } from '@/components/ui/button'
import { getWisdomSync, type ProfileScope, retryWisdomSync } from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2, RefreshCw } from '@/lib/icons'
export function WisdomSyncStatus({ profile }: { profile?: ProfileScope }) {
const scope = capabilityScoped(profile)
return <SyncStatus key={profileScopeKey(scope)} profile={scope} />
}
function SyncStatus({ profile }: { profile?: ProfileScope }) {
const { t } = useI18n()
const copy = t.skills.collective.syncRecovery ?? wisdomSyncCopy
const [open, setOpen] = useState(false)
const [scope] = useState(profile)
const [view, setView] = useState(initialWisdomSyncView)
const controller = useRef<ReturnType<typeof createWisdomSyncController> | null>(null)
// eslint-disable-next-line no-restricted-syntax -- Effect-owned controller, disposed on close/profile change.
useEffect(() => {
if (!open) {return}
const next = createWisdomSyncController({
read: () => getWisdomSync(scope),
retry: () => retryWisdomSync(scope),
changed: setView
})
controller.current = next
void next.refresh()
const poll = () => {
if (document.visibilityState !== 'hidden') {void next.refresh()}
}
const timer = window.setInterval(poll, 15_000)
return () => {
next.dispose()
controller.current = null
window.clearInterval(timer)
}
}, [open, scope])
return (
<section className="min-w-0 border-b border-(--ui-stroke-tertiary) py-2">
<button
aria-expanded={open}
className="flex max-w-full items-center gap-2 text-sm"
onClick={() => setOpen(value => !value)}
type="button"
>
<RefreshCw aria-hidden="true" className="size-4 shrink-0" />
<span className="break-words text-start">{copy.title}</span>
</button>
{open && (
<div className="mt-3 min-w-0 space-y-3">
<p className="text-xs text-muted-foreground">{copy.scope}</p>
{view.error && (
<p className="text-xs text-destructive" role="alert">
{copy.unavailable}
</p>
)}
{!view.snapshot && view.busy && (
<p className="text-xs" role="status">
{t.common.loading}
</p>
)}
{view.snapshot && !view.error && (
<dl aria-live="polite" className="space-y-2 text-xs">
{(['delivery', 'operation'] as const).map(kind => {
const counts = view.snapshot![kind]
const states = (Object.keys(counts) as WisdomSyncState[]).filter(state => counts[state] > 0)
return (
<div key={kind}>
<dt className="font-medium">{copy[kind]}</dt>
<dd className="break-words text-muted-foreground">
{states.length
? states.map(state => (
<div key={state}>
{copy.states[state]}: {counts[state]}
</div>
))
: copy.current}
</dd>
</div>
)
})}
</dl>
)}
<div className="flex flex-wrap gap-2">
<Button
aria-label={t.common.refresh}
disabled={view.busy}
onClick={() => void controller.current?.refresh()}
size="sm"
title={t.common.refresh}
type="button"
variant="outline"
>
<RefreshCw aria-hidden="true" className="size-4" />
</Button>
<Button
disabled={view.busy || view.error || !view.snapshot?.can_retry}
onClick={() => void controller.current?.retry()}
size="sm"
type="button"
>
{view.busy && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
{copy.retry}
</Button>
</div>
</div>
)}
</section>
)
}
@@ -7,13 +7,16 @@ import {
getSkills,
getToolsets,
getUsageAnalytics,
getWisdomStatus,
installSkillFromHub,
profileScopeKey,
reviseWisdomDraft,
saveMcpServers,
setApiRequestConnection,
setApiRequestProfile,
setSkillEnabled,
setToolsetEnabled
setToolsetEnabled,
suggestWisdomSkill
} from './hermes'
// Contract: the Capabilities surface (skills / toolsets / MCP / hub / config)
@@ -82,6 +85,27 @@ describe('capability helpers are connection-scoped', () => {
void setToolsetEnabled('browser', true, { connectionId: 'homelab', profile: 'inbox-bot' })
void saveMcpServers({}, { connectionId: 'homelab', profile: 'inbox-bot' })
void installSkillFromHub('official/research/arxiv', { connectionId: 'homelab', profile: 'inbox-bot' })
void getWisdomStatus({ connectionId: 'homelab', profile: 'inbox-bot' })
void suggestWisdomSkill(
'local-skill',
{ connectionId: 'homelab', profile: 'inbox-bot' },
{
description: 'Owner copy',
systemSpecification: { hermes: { minimum_version: '0.17.0' } }
},
'local-skill-id'
)
void reviseWisdomDraft(
'draft-1',
'Owner copy',
[{ path: 'SKILL.md', content_utf8: '# Skill' }],
{
content: 'sha256:content',
author_description: 'sha256:description',
package_manifest: 'sha256:manifest'
},
{ connectionId: 'homelab', profile: 'inbox-bot' }
)
for (const call of api.mock.calls) {
expect((call[0] as { connectionId?: string }).connectionId).toBe('homelab')
@@ -89,6 +113,31 @@ describe('capability helpers are connection-scoped', () => {
}
})
it('keeps local candidate evidence out of Wisdom mutation bodies', () => {
void suggestWisdomSkill(
'local-skill',
'research',
{
description: 'Owner copy',
systemSpecification: { hermes: { minimum_version: '0.17.0' } }
},
'local-skill-id'
)
expect(api.mock.calls.at(-1)?.[0]).toMatchObject({
body: {
description: 'Owner copy',
local_skill_id: 'local-skill-id',
skill: 'local-skill',
system_specification: { hermes: { minimum_version: '0.17.0' } }
},
method: 'POST',
path: '/api/wisdom/suggest',
profile: 'research'
})
expect(JSON.stringify(api.mock.calls.at(-1)?.[0])).not.toMatch(/usage|refinement|candidate|ranking|stability/)
})
it("a 'local' pin carries an explicit connectionId even while a remote gateway is active", () => {
setApiRequestProfile('research')
setApiRequestConnection('gw-tailscale')
+39
View File
@@ -7,6 +7,7 @@ import {
AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,
audioSpeakRequestTimeoutMs,
audioTranscribeRequestTimeoutMs,
chooseWisdomMute,
deleteProfile,
deleteSession,
getAllSessionMessages,
@@ -21,11 +22,14 @@ import {
getSession,
getSessionMessages,
getStatus,
getWisdomEntitlement,
getWisdomMute,
LATEST_SESSION_MESSAGES_LIMIT,
listAllProfileSessions,
listSessions,
listSidebarSessions,
pluginSocket,
prepareWisdomMute,
resetSidebarBatchCapability,
setApiRequestConnection,
setApiRequestProfile,
@@ -73,6 +77,41 @@ describe('Hermes REST helpers', () => {
)
})
it('pins Wisdom preference reads and native choices to the selected backend and profile', async () => {
const scope = { connectionId: 'source-a', profile: 'worker' }
const controlId = 'a'.repeat(32)
setApiRequestConnection('other-backend')
setApiRequestProfile('other-profile')
await getWisdomMute(scope)
await prepareWisdomMute(scope)
await chooseWisdomMute(controlId, null, scope)
expect(api).toHaveBeenCalledTimes(3)
for (const [request] of api.mock.calls) {
expect(request).toEqual(expect.objectContaining(scope))
}
expect(api).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: '/api/wisdom/mute' }))
expect(api).toHaveBeenNthCalledWith(2, expect.objectContaining({
path: '/api/wisdom/mute/prepare', method: 'POST', body: {}
}))
expect(api).toHaveBeenNthCalledWith(3, expect.objectContaining({
path: '/api/wisdom/mute/choose', method: 'POST', body: { control_id: controlId, duration: null }
}))
})
it('reads Wisdom entitlement from the dedicated scoped endpoint', async () => {
const scope = { connectionId: 'source-a', profile: 'worker' }
await getWisdomEntitlement(scope)
expect(api).toHaveBeenCalledWith(
expect.objectContaining({ ...scope, path: '/api/wisdom/entitlement' })
)
})
it('uses a longer timeout for the all-profile session list', async () => {
await listAllProfileSessions(50, 1)
+1
View File
@@ -28,6 +28,7 @@ export * from './api/sessions'
export * from './api/skills'
export * from './api/system'
export * from './api/toolsets'
export * from './api/wisdom'
export type {
ActionResponse,
+171
View File
@@ -1115,6 +1115,177 @@ export const ar = defineLocale({
}
},
skills: {
collective: {
notificationPreferences: {
title: "إعدادات الإشعارات",
scope: "إشعاراتك الاستباقية عبر تطبيقات هذه المؤسسة. يبقى التصفح والمشاركة اليدويان متاحين.",
on: "الإشعارات مفعلة",
muted: "الإشعارات مكتومة",
day: "يوم واحد",
week: "أسبوع واحد",
month: "30 يومًا",
forever: "بلا حد زمني",
pending: "تم حفظ اختيارك محليًا وهو بانتظار المزامنة.",
failed: "فشلت المزامنة. حدّث الإعدادات قبل الاختيار مجددًا.",
conflict: "تغير تفضيلك في تطبيق آخر. حدّث لمراجعته.",
expired: "انتهت صلاحية هذا الاختيار. حدّث الإعدادات للمحاولة مجددًا.",
},
title: 'الحكمة الجماعية',
loading: 'جارٍ تحميل الحكمة الجماعية…',
unavailable: 'الحكمة الجماعية غير متاحة.',
setup: 'لم يتم إعداد الحكمة الجماعية لهذا الملف الشخصي.',
setupDisclosure:
'يبقى تأهيل المرشحين في هذا الملف الشخصي. لا تصل إلى البوابة إلا محتويات المسودة الخاصة التي وافق عليها المالك، ونص المؤلف، وبيانات البيان التعريفية التصريحية، وحالة التثبيت المُدار.',
setupAction: 'أفهم ذلك — إعداد هذا الملف الشخصي',
settingUp: 'جارٍ الإعداد…',
scanLocal: 'فحص المهارات المحلية',
orgWide: 'مجموعة على مستوى المؤسسة',
sharedSkills: count => `${count} من المهارات المشتركة`,
localCandidates: count => `${count} من الاقتراحات المؤهلة`,
contributionWorkflow: 'سير عمل المساهمة',
potential: 'مساهمات مقترحة',
potentialHelp: 'مهارات أهلها Hermes محليًا بناءً على الاستخدام أو التحسينات المهمة. لن تتم مشاركتها قبل مراجعتك.',
noSuggestions: 'لا توجد حاليًا مهارات محلية تستوفي قواعد التأهيل التلقائي.',
browseLocal: count => `عرض كل المهارات المحلية (${count})`,
browseLocalHelp: 'الاختيار اليدوي لا يعني أن Hermes استخدم هذه المهارات أو أهلها تلقائيًا.',
ownerReview: 'مسودات مساهماتك',
ownerReviewHelp: 'مسودات تنتظر مراجعتك وطلبات تنتظر موافقة المجموعة.',
noDrafts: 'لا توجد مسودات أو طلبات مساهمة نشطة.',
noShared: 'لا توجد مهارات مشتركة تطابق هذا البحث.',
noDescription: 'لا يوجد وصف كتبه المالك',
serverScanPassed: 'نجح فحص الخادم',
localOnly: 'متاحة للمشاركة من هذا الجهاز.',
qualifiedLocally: 'حدّد Hermes هذه المهارة المحلية كمساهمة محتملة.',
qualificationFirst: organizationName =>
`${organizationName ? `فعّلت مؤسستك (${organizationName})` : 'فعّلت مؤسستك'} Collective Wisdom، وهي ميزة مصممة لاكتشاف المهارات المفيدة تلقائيًا لدى جميع أعضاء الفريق. تهانينا! اكتشف Hermes مهارة قد تكون مفيدة لفريقك.`,
qualificationReturning: 'اكتشف Hermes مهارة أخرى قد تكون مفيدة لفريقك.',
savedLocally: 'تم حفظ مسودة خاصة على هذا الجهاز.',
prepare: 'بدء المساهمة',
continueDraft: 'متابعة المسودة',
reviewExact: 'عرض التفاصيل',
runSetupStep: 'تشغيل هذه الخطوة',
confirmSetupPrerequisite: 'تأكيد المتطلب المسبق',
setupCommand: 'الأمر المقترح (الطرفية المحلية)',
setupStepApprovalNotice: 'يمنح التأكيد الإذن لهذه الخطوة فقط. لا تدخل بيانات الاعتماد في الدردشة.',
openDraft: 'عرض التفاصيل',
draftState: state => {
const labels: Record<string, string> = {
vetting: 'مراجعة الخادم جارية',
ready: 'جاهزة لمراجعتك',
owner_approved: 'وافقت عليها',
publishing: 'جارٍ النشر',
pending_moderation: 'في انتظار موافقة مسؤول المجموعة',
changes_requested: 'طُلبت تغييرات'
}
return labels[state] || state.replaceAll('_', ' ')
},
authoritative: 'البوابة هي المرجع المعتمد لقرارات النشر وفحص الخادم.',
versionHistory: 'سجل الإصدارات',
versions: 'الإصدارات',
versionDetails: version => `الإصدار ${version}`,
immutableVersion: 'إصدار منشور غير قابل للتغيير',
published: date => `نُشر في ${date}`,
releaseExplanation: 'شرح الخادم',
viewInPortal: 'عرض في Portal',
backToSkill: 'العودة إلى المهارة',
backToVersions: 'العودة إلى الإصدارات',
prepareTitle: 'راجع الحزمة المحلية قبل الرفع',
prepareNotice: 'لا تُضمّن إشارات المرشح المحلية. لا تغادر أي محتويات هذا الملف الشخصي قبل الإرسال.',
ownerDescription: 'وصف كتبه المالك',
systemSpecification: 'مواصفات النظام',
cancel: 'إلغاء',
submit: 'إرسال المسودة',
submitting: 'جارٍ الإرسال…',
readEvery: 'اقرأ كل ملف. ترتبط الموافقة بالقيم الثلاث الدقيقة للتجزئة أدناه.',
publishToTeam: "نشر للفريق",
submitForApproval: "إرسال للموافقة",
publishLocalNotice: "يؤدي التأكيد إلى رفع هذه الحزمة كما هي ونشرها للفريق بعد اجتياز الفحوصات المطلوبة.",
submitLocalNotice: "يؤدي التأكيد إلى رفع هذه الحزمة كما هي لموافقة مؤسستك. لن تُنشر حتى اكتمال المراجعة.",
reloadReview: "إعادة تحميل المراجعة",
editReview:
'عدّل الوصف أو SKILL.md أو البيان التصريحي هنا. ينشئ الحفظ مراجعة خاصة جديدة ويعيد إجراء الفحوصات ويُرجع قيماً جديدة للتجزئة؛ ولا يعيد كتابة المهارة المصدر المحلية.',
editOwnerDescription: 'تعديل الوصف الذي كتبه المالك',
unsavedChanges: 'لم تُفحص هذه التغييرات. احفظها وأعد الفحص قبل الموافقة.',
saveAndRescan: 'حفظ التغييرات وإعادة الفحص',
savingRevision: 'جارٍ الحفظ وإعادة الفحص…',
resetChanges: 'تجاهل التعديلات',
reviewedHashes: 'قيم تجزئة المراجعة التي فحصها الخادم حالياً',
ownerReviewExact: 'المحتوى الدقيق لمراجعة المالك',
localOverlay: 'التراكب المحلي',
close: 'إغلاق',
approve: 'الموافقة على المحتوى الدقيق ونشره',
publishing: 'جارٍ النشر…',
proposalTitle: 'النشر في المجموعة — الموافقة مطلوبة',
localSuggestion: 'اقتراح محلي',
sharePrompt: 'هل ترغب في المشاركة؟',
reviewFirst: 'المراجعة أولاً',
notNow: 'ليس الآن',
yes: 'نعم',
share: 'مشاركة',
reviewPreviousPage: 'صفحة المراجعة السابقة',
reviewNextPage: 'صفحة المراجعة التالية',
sharePreparationNotice: 'تُجهّز المشاركة حزمة محلية. ستراجعها وتوافق عليها بشكل منفصل قبل رفع أي شيء أو نشره.',
muteNotificationsSoon: 'كتم الإشعارات (قريباً)',
unmuteNotificationsSoon: 'إلغاء كتم الإشعارات (قريباً)',
openCollective: 'فتح المجموعة',
prepareExact: 'إعداد الحزمة الدقيقة',
specificationNotice: 'راجع نص المالك ومواصفات النظام التصريحية. لا يُصرّح بأي إجراء على التبعيات.',
openFullReview: 'فتح المراجعة الكاملة',
sendPrivateReview: 'إرسال المسودة',
serverEnforced: 'يفرضه الخادم',
localAdvisory: 'إرشاد محلي: فحص مستقل قبل الإرسال',
serverReviewNotice: 'اقرأ كل ملف خام أدناه. ترتبط الموافقة بقيم التجزئة الدقيقة المعتمدة من الخادم.',
decline: 'رفض',
approvePublish: 'الموافقة والنشر',
checkUpdates: count => `التحقق من التحديثات${count ? ` (${count})` : ''}`,
checking: 'جارٍ التحقق…',
refreshShared: 'تحديث المهارات المشتركة',
refreshingShared: 'جارٍ التحديث…',
installReferenceLabel: 'التثبيت من رابط أو معرّف مهارة',
installReferencePlaceholder: 'الصق رابط Portal أو معرّف المهارة أو skill-id@vN',
installReferenceHelp: 'يتحقق Hermes من الإصدار الدقيق ويعرض خطة التوافق قبل التثبيت.',
reviewInstall: 'مراجعة التثبيت',
planningInstall: 'جارٍ التحقق…',
updateModeLabel: 'التحديثات المستقبلية',
updateModeDefault: 'استخدام الإعداد الافتراضي للمؤسسة',
updateModeManual: 'يدوي',
updateModeAutomatic: 'تلقائي مع إشعار',
updateModeRequired: 'إلزامي',
updateModeHelp: 'تطبق Gateway سياسة مؤسستك الحالية. ولا تزال التغييرات الحساسة أمنيًا تتطلب موافقتك.',
updateModePlan: mode => `التحديثات المستقبلية: ${mode}`,
install: 'تثبيت…',
uninstall: 'إلغاء التثبيت…',
checkSkill: 'التحقق من هذه المهارة',
updateAvailable: version => (version ? `يتوفر التحديث v${version}` : 'يتوفر تحديث'),
reviewUpdate: 'مراجعة التحديث',
installed: (version, mode) => `مثبتة v${version} · ${mode}`,
confirmAction: action => `تأكيد ${action}`,
acceptCompatibility: 'راجعت إجراءات التوافق وأوافق عليها.',
acceptSensitive: 'أوافق صراحةً على المتطلبات الحساسة الجديدة.',
preserveModified: 'احتفظ أولاً بنسختي المعدلة كتفرع غير مُدار.',
alreadyCurrent: 'هذه المهارة المُدارة محدّثة بالفعل.',
ownerCopyLabel: 'وصف كتبه المالك (لم تتحقق منه المنصة)',
serverFactsLabel: 'فحص يفرضه الخادم وحقائق يستنتجها الخادم',
notifications: 'تحديثات الحكمة الجماعية',
activityReady: count => `${count} من الإشعارات الجديدة`,
aSkill: 'مهارة من الحكمة الجماعية',
decisionPublished: skill => `تمت الموافقة على ${skill} وأصبحت مشتركة مع فريقك.`,
decisionChanges: skill => `تحتاج ${skill} إلى تغييرات قبل مشاركتها.`,
decisionDeclined: skill => `لم تتم الموافقة على مشاركة ${skill}.`,
decisionChanged: (skill, state) => `تغيّرت حالة مساهمة ${skill} إلى ${state}.`,
installedNotice: (skill, version) => `تم تثبيت ${skill}${version ? ` ${version}` : ''} في هذا الملف الشخصي.`,
updatedNotice: (skill, version) => `تم تحديث ${skill}${version ? ` ${version}` : ''} في هذا الملف الشخصي.`,
updateNotice: (skill, version) => `يتوفر تحديث لـ ${skill}${version ? ` ${version}` : ''}.`,
newSkillNotice: skill => `تمت مشاركة ${skill} مع مجموعتك.`,
unavailableNotice: skill => `لم تعد ${skill} متاحة لهذا الملف الشخصي.`,
archivedNotice: skill => `لم تعد ${skill} متاحة للتثبيتات الجديدة.`,
takedownNotice: skill => `تمت إزالة ${skill} من المجموعة.`,
viewSkill: 'عرض المهارة',
markSeen: 'تعليم الكل كمقروء'
},
tabCollective: 'المعرفة الجماعية',
searchCollective: 'البحث في المعرفة الجماعية...',
tabSkills: 'المهارات',
tabToolsets: 'مجموعات الأدوات',
all: 'الكل',
+197
View File
@@ -1529,13 +1529,210 @@ export const en: Translations = {
},
skills: {
collective: {
notificationPreferences: {
title: "Notification settings",
scope: "Your proactive notifications across clients in this organization. Manual browsing and sharing remain available.",
on: "Notifications on",
muted: "Notifications muted",
day: "1 day",
week: "1 week",
month: "30 days",
forever: "Indefinitely",
pending: "Your choice is saved locally and waiting to sync.",
failed: "Sync failed. Refresh settings before choosing again.",
conflict: "Your preference changed on another client. Refresh to review it.",
expired: "This choice expired. Refresh settings to try again.",
},
title: 'Collective Wisdom',
loading: 'Loading Collective Wisdom…',
unavailable: 'Collective Wisdom is unavailable.',
setup: 'Collective Wisdom is not set up for this profile.',
setupDisclosure:
'Candidate qualification stays on this profile. Only owner-approved private draft bytes, author copy, declarative manifest metadata, and managed-install state reach the Gateway.',
setupAction: 'I understand — set up this profile',
settingUp: 'Setting up…',
scanLocal: 'Scan local skills',
orgWide: 'organization-wide collective',
sharedSkills: count => `${count} shared skills`,
localCandidates: count => `${count} qualified suggestion${count === 1 ? '' : 's'}`,
contributionWorkflow: 'Contribution workflow',
potential: 'Suggested contributions',
potentialHelp:
'Skills Hermes qualified locally from use or meaningful refinement. Nothing is shared until you review it.',
noSuggestions: 'No local skills currently meet the automatic qualification rules.',
browseLocal: count => `View all local skills (${count})`,
browseLocalHelp: 'Manual selection does not mean Hermes used or automatically qualified these skills.',
ownerReview: 'Your contribution drafts',
ownerReviewHelp: 'Drafts awaiting your review and submissions waiting for collective approval.',
noDrafts: 'No active contribution drafts or submissions.',
noShared: 'No shared skills match this search.',
noDescription: 'No owner-authored description',
serverScanPassed: 'server scan passed',
localOnly: 'Available to share from this device.',
qualifiedLocally: 'Hermes identified this local skill as a possible contribution.',
qualificationFirst: organizationName =>
`${organizationName ? `Your organization (${organizationName})` : 'Your organization'} has enabled Collective Wisdom, a feature designed to automatically detect and share useful skills across all team members. Congratulations! Hermes detected a skill you created that could be useful to your team!`,
qualificationReturning: 'Hermes detected another skill you created that could be useful to your team!',
savedLocally: 'A private draft is saved on this device.',
prepare: 'Start contribution',
continueDraft: 'Continue draft',
reviewExact: 'View details',
runSetupStep: 'Run this step',
confirmSetupPrerequisite: 'Confirm prerequisite',
setupCommand: 'Proposed command (local terminal)',
setupStepApprovalNotice: 'Only this step is authorized by confirming. Do not enter credentials in chat.',
openDraft: 'View details',
draftState: state => {
const labels: Record<string, string> = {
vetting: 'Server review in progress',
ready: 'Ready for your review',
owner_approved: 'Approved by you',
publishing: 'Publishing',
pending_moderation: 'Waiting for collective administrator approval',
changes_requested: 'Changes requested'
}
return labels[state] || state.replaceAll('_', ' ')
},
authoritative: 'Gateway is authoritative for publication and server scan decisions.',
versionHistory: 'Version history',
versions: 'Versions',
versionDetails: version => `Version ${version}`,
immutableVersion: 'Immutable published version',
published: date => `Published ${date}`,
releaseExplanation: 'Server explanation',
viewInPortal: 'View in Portal',
backToSkill: 'Back to skill',
backToVersions: 'Back to versions',
prepareTitle: 'Review local package before upload',
prepareNotice: 'Local candidate signals are excluded. No bytes leave this profile until Submit.',
ownerDescription: 'Owner-authored description',
systemSpecification: 'System Specification',
cancel: 'Cancel',
submit: 'Submit draft',
submitting: 'Submitting…',
readEvery: 'Read every file. Approval is bound to the exact three hashes below.',
publishToTeam: 'Publish to team',
submitForApproval: 'Submit for approval',
publishLocalNotice: 'Confirming uploads this exact package and publishes it to your team after the required checks.',
submitLocalNotice: 'Confirming uploads this exact package for your organization to approve. It stays unpublished until moderation is complete.',
reloadReview: 'Reload review',
editReview:
'Edit the description, SKILL.md, or declarative manifest here. Saving creates a new private revision, reruns scans, and returns new hashes; it does not rewrite your local source skill.',
editOwnerDescription: 'Edit owner-authored description',
unsavedChanges: 'These changes have not been scanned. Save and rescan before approving.',
saveAndRescan: 'Save changes & rescan',
savingRevision: 'Saving & rescanning…',
resetChanges: 'Discard edits',
reviewedHashes: 'Hashes for the currently server-reviewed revision',
ownerReviewExact: 'Owner review exact content',
localOverlay: 'Local overlay',
close: 'Close',
approve: 'Approve exact content & publish',
publishing: 'Publishing…',
proposalTitle: 'Publish to collective — approval required',
localSuggestion: 'Ready for your review',
preparingLocal: 'Preparing an editable local review…',
whySuggested: 'Why Hermes suggested this skill',
sharePrompt: 'Would you like to share it?',
reviewFirst: 'Review first',
notNow: 'Not Now',
yes: 'Yes',
share: 'Share',
reviewPreviousPage: 'Previous review page',
reviewNextPage: 'Next review page',
sharePreparationNotice: 'Share prepares a local handoff package. You will review it and approve separately before anything is uploaded or published.',
muteNotificationsSoon: 'Mute notifications (coming soon)',
unmuteNotificationsSoon: 'Unmute notifications (coming soon)',
openCollective: 'Open Collective',
prepareExact: 'Review & edit',
skillName: 'Skill name',
whatItDoes: 'What it does',
editDefaultsNotice:
'Review the skill name and instructions. Hermes has prefilled compatibility details from this device; expand them only if they need adjustment.',
detailedRequirements: 'Edit detailed requirements',
hideDetailedRequirements: 'Hide detailed requirements',
specificationNotice:
'Review the owner-authored copy and declarative System Specification. No dependency action is authorized.',
openFullReview: 'Open full review',
sendPrivateReview: 'Submit draft',
saveLocal: 'Save',
savingLocal: 'Saving…',
source: 'Source',
preview: 'Preview',
localDraft: 'local draft',
serverReviewed: 'server reviewed',
serverEnforced: 'server enforced',
localAdvisory: 'local advisory: separate pre-submit scan',
qualificationLabel: 'Qualification',
scanPassed: 'passed',
reviewFindings: 'review findings',
scanAvailable: 'available',
scanUnavailable: 'unavailable',
reviewed: 'reviewed',
contentHash: 'content',
authorDescriptionHash: 'author description',
packageManifestHash: 'package manifest',
serverReviewNotice: 'Read every raw file below. Approval is bound to these exact server-authoritative hashes.',
decline: 'Decline',
approvePublish: 'Approve & publish',
checkUpdates: count => `Check updates${count ? ` (${count})` : ''}`,
checking: 'Checking…',
refreshShared: 'Refresh shared skills',
refreshingShared: 'Refreshing…',
installReferenceLabel: 'Install from link or skill ID',
installReferencePlaceholder: 'Paste a Portal link, skill ID, or skill-id@vN',
installReferenceHelp: 'Hermes verifies the exact version and shows a compatibility plan before installing.',
reviewInstall: 'Review install',
planningInstall: 'Verifying…',
updateModeLabel: 'Future updates',
updateModeDefault: 'Use organization default',
updateModeManual: 'Manual',
updateModeAutomatic: 'Automatic with notice',
updateModeRequired: 'Required',
updateModeHelp:
"Gateway applies your organization's current policy. Safety-sensitive changes still require your approval.",
updateModePlan: mode => `Future updates: ${mode}`,
install: 'Install…',
uninstall: 'Uninstall…',
checkSkill: 'Check this skill',
updateAvailable: version => (version ? `v${version} update available` : 'Update available'),
reviewUpdate: 'Review update',
installed: (version, mode) => `Installed v${version} · ${mode}`,
confirmAction: action => `Confirm ${action}`,
acceptCompatibility: 'I reviewed and accept the compatibility actions.',
acceptSensitive: 'I explicitly accept the new sensitive requirements.',
preserveModified: 'Preserve my modified copy as an unmanaged fork first.',
alreadyCurrent: 'This managed skill is already current.',
ownerCopyLabel: 'Owner-authored description (not platform verified)',
serverFactsLabel: 'Server-enforced scan and server-derived facts',
notifications: 'Collective Wisdom updates',
activityReady: count => `${count} new ${count === 1 ? 'notification' : 'notifications'}`,
aSkill: 'A Collective Wisdom skill',
decisionPublished: skill => `${skill} was approved and is now shared with your team.`,
decisionChanges: skill => `${skill} needs changes before it can be shared.`,
decisionDeclined: skill => `${skill} was not approved for sharing.`,
decisionChanged: (skill, state) => `${skill} contribution status changed to ${state}.`,
installedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} was installed on this profile.`,
updatedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} was updated on this profile.`,
updateNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} is available to update.`,
newSkillNotice: skill => `${skill} was shared with your collective.`,
unavailableNotice: skill => `${skill} is no longer available to this profile.`,
archivedNotice: skill => `${skill} is no longer available for new installs.`,
takedownNotice: skill => `${skill} was removed from the collective.`,
viewSkill: 'View skill',
markSeen: 'Mark all read'
},
tabSkills: 'Skills',
tabToolsets: 'Tools',
configuringProfile: 'Configuring:',
tabMcp: 'MCP',
tabCollective: 'Collective',
all: 'All',
searchSkills: 'Search skills...',
searchToolsets: 'Search tools...',
searchCollective: 'Search the collective...',
refresh: 'Refresh skills',
refreshing: 'Refreshing skills',
loading: 'Loading capabilities...',
+198
View File
@@ -1405,6 +1405,204 @@ export const ja = defineLocale({
},
skills: {
collective: {
notificationPreferences: {
title: "通知設定",
scope: "この組織の各クライアントで受け取る通知を管理します。手動での閲覧や共有は引き続き利用できます。",
on: "通知オン",
muted: "通知ミュート",
day: "1 日",
week: "1 週間",
month: "30 日",
forever: "無期限",
pending: "選択はローカルに保存され、同期待ちです。",
failed: "同期に失敗しました。設定を更新してから選び直してください。",
conflict: "別のクライアントで設定が変更されました。更新して確認してください。",
expired: "この選択は期限切れです。設定を更新して再試行してください。",
},
title: 'コレクティブ・ウィズダム',
loading: 'コレクティブ・ウィズダムを読み込み中…',
unavailable: 'コレクティブ・ウィズダムは利用できません。',
setup: 'このプロファイルではコレクティブ・ウィズダムが設定されていません。',
setupDisclosure:
'候補の適格性評価はこのプロファイル内に保持されます。所有者が承認した非公開ドラフトの内容、作者の説明、宣言的なマニフェストのメタデータ、管理対象インストールの状態だけが Gateway に送信されます。',
setupAction: '理解しました — このプロファイルを設定',
settingUp: '設定中…',
scanLocal: 'ローカルスキルをスキャン',
orgWide: '組織全体のコレクティブ',
sharedSkills: count => `共有スキル ${count} 件`,
localCandidates: count => `適格な提案 ${count} 件`,
contributionWorkflow: '貢献ワークフロー',
potential: '提案された貢献',
potentialHelp:
'使用状況または有意な改善に基づき、Hermes がローカルで適格と判断したスキルです。レビューするまで共有されません。',
noSuggestions: '現在、自動適格性ルールを満たすローカルスキルはありません。',
browseLocal: count => `すべてのローカルスキルを表示 (${count})`,
browseLocalHelp:
'手動選択できることは、Hermes がそのスキルを使用または自動的に適格と判断したことを意味しません。',
ownerReview: 'あなたの貢献ドラフト',
ownerReviewHelp: 'あなたのレビュー待ちのドラフトと、コレクティブの承認待ちの提出です。',
noDrafts: '進行中の貢献ドラフトまたは提出はありません。',
noShared: 'この検索に一致する共有スキルはありません。',
noDescription: '所有者による説明はありません',
serverScanPassed: 'サーバースキャン合格',
localOnly: 'このデバイスから共有できます。',
qualifiedLocally: 'Hermes がこのローカルスキルを貢献候補として認識しました。',
qualificationFirst: organizationName =>
`${organizationName ? `あなたの組織(${organizationName})` : 'あなたの組織'}では、チーム全体から役立つスキルを自動検出する Collective Wisdom が有効になっています。おめでとうございます。Hermes がチームに役立つ可能性のあるスキルを検出しました。`,
qualificationReturning: 'Hermes がチームに役立つ可能性のある別のスキルを検出しました。',
savedLocally: '非公開ドラフトがこのデバイスに保存されています。',
prepare: '貢献を開始',
continueDraft: 'ドラフトを続ける',
reviewExact: '詳細を表示',
runSetupStep: 'この手順を実行',
confirmSetupPrerequisite: '前提条件を確認',
setupCommand: '提案されたコマンド(ローカルターミナル)',
setupStepApprovalNotice: '確認すると、この手順のみが許可されます。認証情報をチャットに入力しないでください。',
openDraft: '詳細を表示',
draftState: state => {
const labels: Record<string, string> = {
vetting: 'サーバーレビュー中',
ready: 'レビュー待ち',
owner_approved: 'あなたが承認済み',
publishing: '公開中',
pending_moderation: 'コレクティブ管理者の承認待ち',
changes_requested: '変更が必要です'
}
return labels[state] || state.replaceAll('_', ' ')
},
authoritative: '公開とサーバースキャンの判断は Gateway が正本です。',
versionHistory: 'バージョン履歴',
versions: 'バージョン',
versionDetails: version => `バージョン ${version}`,
immutableVersion: '変更不可の公開バージョン',
published: date => `${date} に公開`,
releaseExplanation: 'サーバーの説明',
viewInPortal: 'Portal で表示',
backToSkill: 'スキルに戻る',
backToVersions: 'バージョン一覧に戻る',
prepareTitle: 'アップロード前にローカルパッケージを確認',
prepareNotice:
'ローカル候補のシグナルは含まれません。「提出」を押すまで、このプロファイルから内容は送信されません。',
ownerDescription: '所有者による説明',
systemSpecification: 'システム仕様',
cancel: 'キャンセル',
submit: '下書きを提出',
submitting: '提出中…',
readEvery: 'すべてのファイルを確認してください。承認は下記の正確な3つのハッシュに紐づきます。',
publishToTeam: "チームに公開",
submitForApproval: "承認を申請",
publishLocalNotice: "確認すると、このパッケージをアップロードし、必要なチェックの後にチームへ公開します。",
submitLocalNotice: "確認すると、このパッケージを組織の承認に提出します。審査完了までは公開されません。",
reloadReview: "レビューを再読み込み",
editReview:
'ここで説明、SKILL.md、宣言的マニフェストを編集できます。保存すると新しい非公開リビジョンが作成され、スキャンが再実行されて新しいハッシュが返されます。ローカルの元スキルは書き換えません。',
editOwnerDescription: '所有者による説明を編集',
unsavedChanges: 'これらの変更はまだスキャンされていません。承認前に保存して再スキャンしてください。',
saveAndRescan: '変更を保存して再スキャン',
savingRevision: '保存して再スキャン中…',
resetChanges: '編集を破棄',
reviewedHashes: '現在サーバーでレビュー済みのリビジョンのハッシュ',
ownerReviewExact: '所有者がレビューする正確な内容',
localOverlay: 'ローカルオーバーレイ',
close: '閉じる',
approve: '正確な内容を承認して公開',
publishing: '公開中…',
proposalTitle: 'コレクティブに公開 — 承認が必要',
localSuggestion: 'レビューの準備ができました',
preparingLocal: '編集可能なローカルレビューを準備中…',
whySuggested: 'Hermes がこのスキルを提案した理由',
sharePrompt: '共有しますか?',
reviewFirst: '先に確認',
notNow: '後で',
yes: 'はい',
share: '共有',
reviewPreviousPage: '前の確認ページ',
reviewNextPage: '次の確認ページ',
sharePreparationNotice: '共有用のパッケージをローカルで準備します。アップロードや公開の前に、内容を確認して別途承認します。',
muteNotificationsSoon: '通知をミュート(近日対応)',
unmuteNotificationsSoon: '通知のミュートを解除(近日対応)',
openCollective: 'コレクティブを開く',
prepareExact: '確認して編集',
skillName: 'スキル名',
editDefaultsNotice:
'スキル名と手順を確認してください。互換性の詳細は Hermes がこのデバイスから入力済みです。調整が必要な場合のみ展開してください。',
detailedRequirements: '詳細な要件を編集',
hideDetailedRequirements: '詳細な要件を閉じる',
specificationNotice: '所有者による説明と宣言的なシステム仕様を確認してください。依存関係の操作は許可されません。',
openFullReview: '完全なレビューを開く',
sendPrivateReview: '下書きを提出',
saveLocal: '保存',
savingLocal: '保存中…',
source: 'ソース',
preview: 'プレビュー',
localDraft: 'ローカル下書き',
serverReviewed: 'サーバーレビュー済み',
serverEnforced: 'サーバーで強制',
localAdvisory: 'ローカル助言:提出前の独立スキャン',
qualificationLabel: '候補になった理由',
scanPassed: '問題なし',
reviewFindings: '要確認',
scanAvailable: '利用可能',
scanUnavailable: '利用不可',
reviewed: 'レビュー済み',
contentHash: 'コンテンツ',
authorDescriptionHash: '作成者の説明',
packageManifestHash: 'パッケージマニフェスト',
serverReviewNotice:
'下記のすべての生ファイルを確認してください。承認はサーバーが確認した正確なハッシュに紐づきます。',
decline: '辞退',
approvePublish: '承認して公開',
checkUpdates: count => `更新を確認${count ? `(${count})` : ''}`,
checking: '確認中…',
refreshShared: '共有スキルを更新',
refreshingShared: '更新中…',
installReferenceLabel: 'リンクまたはスキル ID からインストール',
installReferencePlaceholder: 'Portal リンク、スキル ID、または skill-id@vN を貼り付け',
installReferenceHelp: 'Hermes はインストール前に正確なバージョンを検証し、互換性プランを表示します。',
reviewInstall: 'インストールを確認',
planningInstall: '検証中…',
updateModeLabel: '今後の更新',
updateModeDefault: '組織のデフォルトを使用',
updateModeManual: '手動',
updateModeAutomatic: '通知付き自動更新',
updateModeRequired: '必須',
updateModeHelp: 'Gateway は組織の現在のポリシーを適用します。安全性に関わる変更には引き続き承認が必要です。',
updateModePlan: mode => `今後の更新: ${mode}`,
install: 'インストール…',
uninstall: 'アンインストール…',
checkSkill: 'このスキルを確認',
updateAvailable: version => (version ? `v${version} に更新可能` : '更新可能'),
reviewUpdate: '更新を確認',
installed: (version, mode) => `インストール済み v${version} · ${mode}`,
confirmAction: action => `${action}を確認`,
acceptCompatibility: '互換性に関する操作を確認し、同意します。',
acceptSensitive: '新しい機密要件を明示的に受け入れます。',
preserveModified: '先に変更済みコピーを管理対象外のフォークとして保持します。',
alreadyCurrent: 'この管理対象スキルはすでに最新です。',
ownerCopyLabel: '所有者による説明(プラットフォーム未検証)',
serverFactsLabel: 'サーバー強制スキャンとサーバー由来の情報',
notifications: 'コレクティブ・ウィズダムの更新',
activityReady: count => `新しい通知 ${count} 件`,
aSkill: 'コレクティブ・ウィズダムのスキル',
decisionPublished: skill => `${skill} は承認され、チームと共有されました。`,
decisionChanges: skill => `${skill} は共有前に変更が必要です。`,
decisionDeclined: skill => `${skill} は共有が承認されませんでした。`,
decisionChanged: (skill, state) => `${skill} の貢献ステータスが ${state} に変わりました。`,
installedNotice: (skill, version) =>
`${skill}${version ? ` ${version}` : ''} をこのプロファイルにインストールしました。`,
updatedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} をこのプロファイルで更新しました。`,
updateNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} の更新を利用できます。`,
newSkillNotice: skill => `${skill} がコレクティブに共有されました。`,
unavailableNotice: skill => `${skill} はこのプロファイルで利用できなくなりました。`,
archivedNotice: skill => `${skill} は新規インストールできなくなりました。`,
takedownNotice: skill => `${skill} はコレクティブから削除されました。`,
viewSkill: 'スキルを表示',
markSeen: 'すべて既読にする'
},
tabCollective: 'コレクティブ',
searchCollective: 'コレクティブを検索...',
tabSkills: 'スキル',
tabToolsets: 'ツールセット',
tabMcp: 'MCP',
+39
View File
@@ -1509,6 +1509,45 @@ export const ru = defineLocale({
}
},
skills: {
collective: {
publishToTeam: 'Опубликовать для команды',
submitForApproval: 'Отправить на одобрение',
publishLocalNotice: 'Подтверждение загрузит именно этот пакет и опубликует его для команды после обязательных проверок.',
submitLocalNotice: 'Подтверждение загрузит именно этот пакет на одобрение организации. До завершения модерации он не будет опубликован.',
reloadReview: 'Обновить проверку',
notificationPreferences: {
title: "Настройки уведомлений",
scope: "Ваши инициативные уведомления во всех клиентах этой организации. Просмотр и публикация вручную остаются доступны.",
on: "Уведомления включены",
muted: "Уведомления отключены",
day: "1 день",
week: "1 неделя",
month: "30 дней",
forever: "Бессрочно",
pending: "Выбор сохранён локально и ожидает синхронизации.",
failed: "Сбой синхронизации. Обновите настройки и выберите снова.",
conflict: "Настройка изменена в другом клиенте. Обновите для проверки.",
expired: "Срок действия выбора истёк. Обновите настройки и повторите.",
},
qualificationFirst: organizationName =>
`${organizationName ? `Ваша организация (${organizationName})` : 'Ваша организация'} включила Collective Wisdom — функцию, которая автоматически находит полезные навыки у всех участников команды. Поздравляем! Hermes обнаружил навык, который может быть полезен вашей команде.`,
qualificationReturning: 'Hermes обнаружил ещё один навык, который может быть полезен вашей команде.',
sendPrivateReview: 'Отправить черновик',
sharePrompt: 'Хотите поделиться?',
reviewFirst: 'Сначала проверить',
runSetupStep: 'Выполнить этот шаг',
confirmSetupPrerequisite: 'Подтвердить предварительное условие',
setupCommand: 'Предлагаемая команда (локальный терминал)',
setupStepApprovalNotice: 'Подтверждение разрешает только этот шаг. Не вводите учётные данные в чате.',
notNow: 'Не сейчас',
yes: 'Да',
share: 'Поделиться',
reviewPreviousPage: 'Предыдущая страница проверки',
reviewNextPage: 'Следующая страница проверки',
sharePreparationNotice: 'Будет подготовлен локальный пакет. Перед загрузкой или публикацией вы отдельно проверите и одобрите его.',
muteNotificationsSoon: 'Отключить уведомления (скоро)',
unmuteNotificationsSoon: 'Включить уведомления (скоро)'
},
tabSkills: 'Навыки',
tabToolsets: 'Инструменты',
configuringProfile: 'Настраивается:',
+171
View File
@@ -5,10 +5,178 @@
// partial locales should use `defineLocale()` so missing desktop-only strings
// fall back to English while new keys remain type-checked.
import type { WisdomMuteCopy, WisdomSyncCopy } from '@hermes/shared'
import type { TipId } from '@/lib/tips/catalog'
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru'
export interface WisdomTranslations {
syncRecovery?: WisdomSyncCopy
notificationPreferences: WisdomMuteCopy
title: string
loading: string
unavailable: string
setup: string
setupDisclosure: string
setupAction: string
settingUp: string
scanLocal: string
orgWide: string
sharedSkills: (count: number) => string
localCandidates: (count: number) => string
contributionWorkflow: string
potential: string
potentialHelp: string
noSuggestions: string
browseLocal: (count: number) => string
browseLocalHelp: string
ownerReview: string
ownerReviewHelp: string
noDrafts: string
noShared: string
noDescription: string
serverScanPassed: string
localOnly: string
qualifiedLocally: string
qualificationFirst: (organizationName?: string | null) => string
qualificationReturning: string
savedLocally: string
prepare: string
continueDraft: string
reviewExact: string
runSetupStep: string
confirmSetupPrerequisite: string
setupCommand: string
setupStepApprovalNotice: string
openDraft: string
draftState: (state: string) => string
authoritative: string
versionHistory: string
versions: string
versionDetails: (version: number) => string
immutableVersion: string
published: (date: string) => string
releaseExplanation: string
viewInPortal: string
backToSkill: string
backToVersions: string
prepareTitle: string
prepareNotice: string
ownerDescription: string
systemSpecification: string
cancel: string
submit: string
submitting: string
readEvery: string
publishToTeam: string
submitForApproval: string
publishLocalNotice: string
submitLocalNotice: string
reloadReview: string
editReview: string
editOwnerDescription: string
unsavedChanges: string
saveAndRescan: string
savingRevision: string
resetChanges: string
reviewedHashes: string
ownerReviewExact: string
localOverlay: string
close: string
approve: string
publishing: string
proposalTitle: string
localSuggestion: string
preparingLocal: string
whySuggested: string
sharePrompt: string
reviewFirst: string
notNow: string
yes: string
share: string
sharePreparationNotice: string
reviewPreviousPage: string
reviewNextPage: string
muteNotificationsSoon: string
unmuteNotificationsSoon: string
openCollective: string
prepareExact: string
skillName: string
whatItDoes: string
editDefaultsNotice: string
detailedRequirements: string
hideDetailedRequirements: string
specificationNotice: string
openFullReview: string
sendPrivateReview: string
saveLocal: string
savingLocal: string
source: string
preview: string
localDraft: string
serverReviewed: string
serverEnforced: string
localAdvisory: string
qualificationLabel: string
scanPassed: string
reviewFindings: string
scanAvailable: string
scanUnavailable: string
reviewed: string
contentHash: string
authorDescriptionHash: string
packageManifestHash: string
serverReviewNotice: string
decline: string
approvePublish: string
checkUpdates: (count: number) => string
checking: string
refreshShared: string
refreshingShared: string
installReferenceLabel: string
installReferencePlaceholder: string
installReferenceHelp: string
reviewInstall: string
planningInstall: string
updateModeLabel: string
updateModeDefault: string
updateModeManual: string
updateModeAutomatic: string
updateModeRequired: string
updateModeHelp: string
updateModePlan: (mode: string) => string
install: string
uninstall: string
checkSkill: string
updateAvailable: (version?: number) => string
reviewUpdate: string
installed: (version: number, mode: string) => string
confirmAction: (action: string) => string
acceptCompatibility: string
acceptSensitive: string
preserveModified: string
alreadyCurrent: string
ownerCopyLabel: string
serverFactsLabel: string
notifications: string
activityReady: (count: number) => string
aSkill: string
decisionPublished: (skill: string) => string
decisionChanges: (skill: string) => string
decisionDeclined: (skill: string) => string
decisionChanged: (skill: string, state: string) => string
installedNotice: (skill: string, version?: string) => string
updatedNotice: (skill: string, version?: string) => string
updateNotice: (skill: string, version?: string) => string
newSkillNotice: (skill: string) => string
unavailableNotice: (skill: string) => string
archivedNotice: (skill: string) => string
takedownNotice: (skill: string) => string
viewSkill: string
markSeen: string
}
export type ToolTitleKey =
| 'browser_click'
| 'browser_fill'
@@ -1341,13 +1509,16 @@ export interface Translations {
}
skills: {
collective: WisdomTranslations
tabSkills: string
tabToolsets: string
configuringProfile: string
tabMcp: string
tabCollective: string
all: string
searchSkills: string
searchToolsets: string
searchCollective: string
refresh: string
refreshing: string
loading: string
+192
View File
@@ -1349,6 +1349,198 @@ export const zhHant = defineLocale({
},
skills: {
collective: {
publishToTeam: '發布至團隊',
submitForApproval: '提交審核',
publishLocalNotice: '確認後將上傳此確切的套件,並在通過必要檢查後發布至團隊。',
submitLocalNotice: '確認後將上傳此確切的套件供組織審核。審核完成前不會發布。',
reloadReview: '重新載入審核內容',
notificationPreferences: {
title: "通知設定",
scope: "管理此組織中跨用戶端的主動通知。手動瀏覽和分享仍可使用。",
on: "通知已開啟",
muted: "通知已靜音",
day: "1 天",
week: "1 週",
month: "30 天",
forever: "無限期",
pending: "選擇已儲存在本機,正在等待同步。",
failed: "同步失敗。請重新整理設定後重新選擇。",
conflict: "其他用戶端變更了偏好。請重新整理查看。",
expired: "此選擇已過期。請重新整理設定後再試。",
},
title: '集體智慧',
loading: '正在載入集體智慧…',
unavailable: '集體智慧目前無法使用。',
setup: '此設定檔尚未設定集體智慧。',
setupDisclosure:
'候選資格評估會保留在此設定檔中。只有經擁有者核准的私人草稿內容、作者文案、宣告式資訊清單中繼資料和受管理安裝狀態會傳送到閘道。',
setupAction: '我已瞭解 — 設定此設定檔',
settingUp: '正在設定…',
scanLocal: '掃描本機技能',
orgWide: '組織範圍的集體',
sharedSkills: count => `${count} 個共享技能`,
localCandidates: count => `${count} 個合格建議`,
contributionWorkflow: '貢獻流程',
potential: '建議的貢獻',
potentialHelp: 'Hermes 根據本機使用或有意義的改進判定為合格的技能。在你審核前不會共享。',
noSuggestions: '目前沒有本機技能符合自動資格規則。',
browseLocal: count => `檢視所有本機技能 (${count})`,
browseLocalHelp: '手動選擇並不表示 Hermes 使用過這些技能或已自動判定其合格。',
ownerReview: '你的貢獻草稿',
ownerReviewHelp: '等待你審核的草稿,以及等待集體核准的提交。',
noDrafts: '沒有進行中的貢獻草稿或提交。',
noShared: '沒有符合此搜尋的共享技能。',
noDescription: '沒有擁有者撰寫的描述',
serverScanPassed: '伺服器掃描已通過',
localOnly: '可從此裝置共享。',
qualifiedLocally: 'Hermes 將此本機技能識別為可能的貢獻。',
qualificationFirst: organizationName =>
`${organizationName ? `您的組織(${organizationName})` : '您的組織'}已啟用 Collective Wisdom,此功能會自動探索所有團隊成員的實用技能。恭喜!Hermes 偵測到一項可能對您的團隊有用的技能。`,
qualificationReturning: 'Hermes 又偵測到一項可能對您的團隊有用的技能。',
savedLocally: '私人草稿已儲存在此裝置上。',
prepare: '開始貢獻',
continueDraft: '繼續編輯草稿',
reviewExact: '檢視詳細資料',
runSetupStep: '執行此步驟',
confirmSetupPrerequisite: '確認前提條件',
setupCommand: '建議的命令(本機終端機)',
setupStepApprovalNotice: '確認僅授權此步驟。請勿在聊天中輸入憑證。',
openDraft: '檢視詳細資料',
draftState: state => {
const labels: Record<string, string> = {
vetting: '伺服器正在審核',
ready: '等待你審核',
owner_approved: '你已核准',
publishing: '正在發佈',
pending_moderation: '等待集體管理員核准',
changes_requested: '已要求修改'
}
return labels[state] || state.replaceAll('_', ' ')
},
authoritative: '閘道是發佈和伺服器掃描決定的權威來源。',
versionHistory: '版本歷程',
versions: '版本',
versionDetails: version => `版本 ${version}`,
immutableVersion: '不可變的已發佈版本',
published: date => `發佈於 ${date}`,
releaseExplanation: '伺服器說明',
viewInPortal: '在 Portal 中檢視',
backToSkill: '返回技能',
backToVersions: '返回版本列表',
prepareTitle: '上傳前審核本機套件',
prepareNotice: '不包含本機候選訊號。在點選「提交」前,不會有任何內容離開此設定檔。',
ownerDescription: '擁有者撰寫的描述',
systemSpecification: '系統規格',
cancel: '取消',
submit: '提交草稿',
submitting: '正在提交…',
readEvery: '請閱讀每個檔案。核准將綁定到下方三個精確雜湊值。',
editReview:
'可在此編輯描述、SKILL.md 或宣告式資訊清單。儲存會建立新的私人修訂、重新執行掃描並傳回新雜湊值;不會改寫你的本機來源技能。',
editOwnerDescription: '編輯擁有者撰寫的描述',
unsavedChanges: '這些變更尚未掃描。請先儲存並重新掃描,再進行核准。',
saveAndRescan: '儲存變更並重新掃描',
savingRevision: '正在儲存並重新掃描…',
resetChanges: '捨棄編輯',
reviewedHashes: '目前伺服器已審核修訂的雜湊值',
ownerReviewExact: '擁有者審核的精確內容',
localOverlay: '本機覆疊',
close: '關閉',
approve: '核准精確內容並發佈',
publishing: '正在發佈…',
proposalTitle: '發佈到集體 — 需要核准',
localSuggestion: '可供你審核',
preparingLocal: '正在準備可編輯的本機審核…',
whySuggested: 'Hermes 建議此技能的原因',
sharePrompt: '你想分享嗎?',
reviewFirst: '先審核',
notNow: '暫不',
yes: '是',
share: '分享',
reviewPreviousPage: '上一審閱頁',
reviewNextPage: '下一審閱頁',
sharePreparationNotice: '分享會先在本機準備交接套件。在上傳或發佈任何內容之前,你需要另外審閱並核准。',
muteNotificationsSoon: '將通知靜音(即將推出)',
unmuteNotificationsSoon: '取消通知靜音(即將推出)',
openCollective: '開啟集體',
prepareExact: '審核並編輯',
skillName: '技能名稱',
editDefaultsNotice: '請審核技能名稱和說明。Hermes 已根據此裝置預填相容性詳情;僅在需要調整時展開。',
detailedRequirements: '編輯詳細需求',
hideDetailedRequirements: '收合詳細需求',
specificationNotice: '請審核擁有者撰寫的文案和宣告式系統規格。此操作不會授權執行任何相依項目。',
openFullReview: '開啟完整審核',
sendPrivateReview: '提交草稿',
saveLocal: '儲存',
savingLocal: '正在儲存…',
source: '原始碼',
preview: '預覽',
localDraft: '本機草稿',
serverReviewed: '伺服器已審核',
serverEnforced: '伺服器強制執行',
localAdvisory: '本機建議:獨立的提交前掃描',
qualificationLabel: '入選原因',
scanPassed: '已通過',
reviewFindings: '請檢查發現項目',
scanAvailable: '可用',
scanUnavailable: '不可用',
reviewed: '已審核',
contentHash: '內容',
authorDescriptionHash: '作者說明',
packageManifestHash: '套件資訊清單',
serverReviewNotice: '請閱讀下方每個原始檔案。核准將綁定到這些由伺服器確認的精確雜湊值。',
decline: '拒絕',
approvePublish: '核准並發佈',
checkUpdates: count => `檢查更新${count ? `(${count})` : ''}`,
checking: '正在檢查…',
refreshShared: '重新整理共享技能',
refreshingShared: '正在重新整理…',
installReferenceLabel: '從連結或技能 ID 安裝',
installReferencePlaceholder: '貼上 Portal 連結、技能 ID 或 skill-id@vN',
installReferenceHelp: 'Hermes 會先驗證確切版本並顯示相容性計畫,再進行安裝。',
reviewInstall: '檢查安裝',
planningInstall: '正在驗證…',
updateModeLabel: '後續更新',
updateModeDefault: '使用組織預設設定',
updateModeManual: '手動',
updateModeAutomatic: '自動更新並通知',
updateModeRequired: '必須更新',
updateModeHelp: 'Gateway 會套用組織目前的政策。涉及安全的變更仍需你的核准。',
updateModePlan: mode => `後續更新:${mode}`,
install: '安裝…',
uninstall: '解除安裝…',
checkSkill: '檢查此技能',
updateAvailable: version => (version ? `可更新至 v${version}` : '有可用更新'),
reviewUpdate: '檢視更新',
installed: (version, mode) => `已安裝 v${version} · ${mode}`,
confirmAction: action => `確認${action}`,
acceptCompatibility: '我已審核並接受相容性操作。',
acceptSensitive: '我明確接受新的敏感需求。',
preserveModified: '先將我修改過的副本保留為不受管理的分支。',
alreadyCurrent: '此受管理技能已是最新版本。',
ownerCopyLabel: '擁有者撰寫的描述(未經平台驗證)',
serverFactsLabel: '伺服器強制掃描和伺服器推導的事實',
notifications: '集體智慧更新',
activityReady: count => `${count} 則新通知`,
aSkill: '一個集體智慧技能',
decisionPublished: skill => `${skill} 已獲核准,現在已與你的團隊共享。`,
decisionChanges: skill => `${skill} 需要修改後才能共享。`,
decisionDeclined: skill => `${skill} 未獲共享核准。`,
decisionChanged: (skill, state) => `${skill} 的貢獻狀態已變更為${state}。`,
installedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} 已安裝到此設定檔。`,
updatedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} 已在此設定檔中更新。`,
updateNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} 有可用更新。`,
newSkillNotice: skill => `${skill} 已與你的集體共享。`,
unavailableNotice: skill => `${skill} 在此設定檔中已無法使用。`,
archivedNotice: skill => `${skill} 已不再可用於新安裝。`,
takedownNotice: skill => `${skill} 已從集體中移除。`,
viewSkill: '查看技能',
markSeen: '全部標示為已讀'
},
tabCollective: '集體智慧',
searchCollective: '搜尋集體智慧...',
tabSkills: '技能',
tabToolsets: '工具集',
tabMcp: 'MCP',
+193
View File
@@ -1698,6 +1698,199 @@ export const zh: Translations = {
},
skills: {
collective: {
publishToTeam: '发布到团队',
submitForApproval: '提交审核',
publishLocalNotice: '确认后将上传此确切的软件包,并在通过必要检查后发布到团队。',
submitLocalNotice: '确认后将上传此确切的软件包供组织审核。审核完成前不会发布。',
reloadReview: '重新加载审核内容',
notificationPreferences: {
title: "通知设置",
scope: "管理此组织中跨客户端的主动通知。手动浏览和分享仍可使用。",
on: "通知已开启",
muted: "通知已静音",
day: "1 天",
week: "1 周",
month: "30 天",
forever: "无限期",
pending: "选择已保存在本地,正在等待同步。",
failed: "同步失败。请刷新设置后重新选择。",
conflict: "其他客户端更改了偏好。请刷新查看。",
expired: "此选择已过期。请刷新设置后重试。",
},
title: '集体智慧',
loading: '正在加载集体智慧…',
unavailable: '集体智慧暂不可用。',
setup: '此配置文件尚未设置集体智慧。',
setupDisclosure:
'候选资格评估保留在此配置文件中。只有经所有者批准的私有草稿内容、作者文案、声明式清单元数据和托管安装状态会发送到网关。',
setupAction: '我已了解 — 设置此配置文件',
settingUp: '正在设置…',
scanLocal: '扫描本地技能',
orgWide: '组织范围的集体',
sharedSkills: count => `${count} 个共享技能`,
localCandidates: count => `${count} 个合格建议`,
contributionWorkflow: '贡献流程',
potential: '建议的贡献',
potentialHelp: 'Hermes 根据本地使用或有意义的改进判定为合格的技能。在你审核前不会共享。',
noSuggestions: '目前没有本地技能符合自动资格规则。',
browseLocal: count => `查看所有本地技能 (${count})`,
browseLocalHelp: '手动选择并不表示 Hermes 使用过这些技能或已自动判定其合格。',
ownerReview: '你的贡献草稿',
ownerReviewHelp: '等待你审核的草稿,以及等待集体批准的提交。',
noDrafts: '没有进行中的贡献草稿或提交。',
noShared: '没有符合此搜索的共享技能。',
noDescription: '没有所有者撰写的描述',
serverScanPassed: '服务器扫描已通过',
localOnly: '可从此设备共享。',
qualifiedLocally: 'Hermes 将此本地技能识别为可能的贡献。',
qualificationFirst: organizationName =>
`${organizationName ? `您的组织(${organizationName})` : '您的组织'}已启用 Collective Wisdom,此功能会自动发现所有团队成员的实用技能。恭喜!Hermes 检测到一项可能对您的团队有用的技能。`,
qualificationReturning: 'Hermes 又检测到一项可能对您的团队有用的技能。',
savedLocally: '私有草稿已保存在此设备上。',
prepare: '开始贡献',
continueDraft: '继续编辑草稿',
reviewExact: '查看详情',
runSetupStep: '运行此步骤',
confirmSetupPrerequisite: '确认前提条件',
setupCommand: '建议的命令(本地终端)',
setupStepApprovalNotice: '确认仅授权此步骤。请勿在聊天中输入凭据。',
openDraft: '查看详情',
draftState: state => {
const labels: Record<string, string> = {
vetting: '服务器正在审核',
ready: '等待你审核',
owner_approved: '你已批准',
publishing: '正在发布',
pending_moderation: '等待集体管理员批准',
changes_requested: '已要求修改'
}
return labels[state] || state.replaceAll('_', ' ')
},
authoritative: '网关是发布和服务器扫描决定的权威来源。',
versionHistory: '版本历史',
versions: '版本',
versionDetails: version => `版本 ${version}`,
immutableVersion: '不可变的已发布版本',
published: date => `发布于 ${date}`,
releaseExplanation: '服务器说明',
viewInPortal: '在 Portal 中查看',
backToSkill: '返回技能',
backToVersions: '返回版本列表',
prepareTitle: '上传前审核本地包',
prepareNotice: '不包含本地候选信号。在点击“提交”前,不会有任何内容离开此配置文件。',
ownerDescription: '所有者撰写的描述',
systemSpecification: '系统规格',
cancel: '取消',
submit: '提交草稿',
submitting: '正在提交…',
readEvery: '请阅读每个文件。批准将绑定到下方三个准确哈希值。',
editReview:
'可在此编辑描述、SKILL.md 或声明式清单。保存会创建新的私有修订、重新运行扫描并返回新哈希值;不会改写你的本地源技能。',
editOwnerDescription: '编辑所有者撰写的描述',
unsavedChanges: '这些更改尚未扫描。请先保存并重新扫描,再进行批准。',
saveAndRescan: '保存更改并重新扫描',
savingRevision: '正在保存并重新扫描…',
resetChanges: '放弃编辑',
reviewedHashes: '当前服务器已审核修订的哈希值',
ownerReviewExact: '所有者审核的准确内容',
localOverlay: '本地叠加层',
close: '关闭',
approve: '批准准确内容并发布',
publishing: '正在发布…',
proposalTitle: '发布到集体 — 需要批准',
localSuggestion: '可供你审核',
preparingLocal: '正在准备可编辑的本地审核…',
whySuggested: 'Hermes 推荐此技能的原因',
sharePrompt: '你想分享吗?',
reviewFirst: '先审核',
notNow: '暂不',
yes: '是',
share: '分享',
reviewPreviousPage: '上一审阅页',
reviewNextPage: '下一审阅页',
sharePreparationNotice: '分享会先在本地准备交接包。在上传或发布任何内容之前,你需要单独审阅并批准。',
muteNotificationsSoon: '屏蔽通知(即将推出)',
unmuteNotificationsSoon: '取消屏蔽通知(即将推出)',
openCollective: '打开集体',
prepareExact: '审核并编辑',
skillName: '技能名称',
whatItDoes: '功能说明',
editDefaultsNotice: '请审核技能名称和说明。Hermes 已根据此设备预填兼容性详情;仅在需要调整时展开。',
detailedRequirements: '编辑详细要求',
hideDetailedRequirements: '收起详细要求',
specificationNotice: '请审核所有者撰写的文案和声明式系统规格。此操作不会授权执行任何依赖项。',
openFullReview: '打开完整审核',
sendPrivateReview: '提交草稿',
saveLocal: '保存',
savingLocal: '正在保存…',
source: '源文件',
preview: '预览',
localDraft: '本地草稿',
serverReviewed: '服务器已审核',
serverEnforced: '服务器强制执行',
localAdvisory: '本地建议:独立的提交前扫描',
qualificationLabel: '入选原因',
scanPassed: '已通过',
reviewFindings: '请检查发现项',
scanAvailable: '可用',
scanUnavailable: '不可用',
reviewed: '已审核',
contentHash: '内容',
authorDescriptionHash: '作者说明',
packageManifestHash: '软件包清单',
serverReviewNotice: '请阅读下方每个原始文件。批准将绑定到这些由服务器确认的准确哈希值。',
decline: '拒绝',
approvePublish: '批准并发布',
checkUpdates: count => `检查更新${count ? `(${count})` : ''}`,
checking: '正在检查…',
refreshShared: '刷新共享技能',
refreshingShared: '刷新中…',
installReferenceLabel: '通过链接或技能 ID 安装',
installReferencePlaceholder: '粘贴 Portal 链接、技能 ID 或 skill-id@vN',
installReferenceHelp: 'Hermes 会先验证准确版本并显示兼容性计划,然后再安装。',
reviewInstall: '检查安装',
planningInstall: '验证中…',
updateModeLabel: '后续更新',
updateModeDefault: '使用组织默认设置',
updateModeManual: '手动',
updateModeAutomatic: '自动更新并通知',
updateModeRequired: '必须更新',
updateModeHelp: 'Gateway 会应用组织的当前策略。涉及安全的变更仍需你的批准。',
updateModePlan: mode => `后续更新:${mode}`,
install: '安装…',
uninstall: '卸载…',
checkSkill: '检查此技能',
updateAvailable: version => (version ? `可更新至 v${version}` : '有可用更新'),
reviewUpdate: '查看更新',
installed: (version, mode) => `已安装 v${version} · ${mode}`,
confirmAction: action => `确认${action}`,
acceptCompatibility: '我已审核并接受兼容性操作。',
acceptSensitive: '我明确接受新的敏感要求。',
preserveModified: '先将我修改过的副本保留为不受管理的分支。',
alreadyCurrent: '此托管技能已经是最新版本。',
ownerCopyLabel: '所有者撰写的描述(未经平台验证)',
serverFactsLabel: '服务器强制扫描和服务器推导的事实',
notifications: '集体智慧更新',
activityReady: count => `${count} 条新通知`,
aSkill: '一个集体智慧技能',
decisionPublished: skill => `${skill} 已获批准,现在已与你的团队共享。`,
decisionChanges: skill => `${skill} 需要修改后才能共享。`,
decisionDeclined: skill => `${skill} 未获共享批准。`,
decisionChanged: (skill, state) => `${skill} 的贡献状态已更改为${state}。`,
installedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} 已安装到此配置文件。`,
updatedNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} 已在此配置文件中更新。`,
updateNotice: (skill, version) => `${skill}${version ? ` ${version}` : ''} 有可用更新。`,
newSkillNotice: skill => `${skill} 已与你的集体共享。`,
unavailableNotice: skill => `${skill} 在此配置文件中已不可用。`,
archivedNotice: skill => `${skill} 已不再可用于新安装。`,
takedownNotice: skill => `${skill} 已从集体中移除。`,
viewSkill: '查看技能',
markSeen: '全部标为已读'
},
tabCollective: '集体智慧',
searchCollective: '搜索集体智慧...',
tabSkills: '技能',
tabToolsets: '工具集',
configuringProfile: '正在配置:',
@@ -53,6 +53,7 @@ const REGISTRY_CATALOG = registryCatalog(
'/btw': 'text',
'/debug': null,
'/goal': 'mixed',
'/wisdom': 'mixed',
'/personality': 'options',
'/queue': 'text',
'/retry': null,
@@ -62,7 +63,13 @@ const REGISTRY_CATALOG = registryCatalog(
'/loop': 'mixed',
'/lcm': 'text'
},
{ '/tasks': '/agents', '/background': '/bg', '/q': '/queue', '/proactive': '/loop' }
{
'/tasks': '/agents',
'/background': '/bg',
'/q': '/queue',
'/proactive': '/loop',
'/collective-wisdom-install': '/wisdom'
}
)
describe('desktop slash command curation', () => {
@@ -103,6 +110,15 @@ describe('desktop slash command curation', () => {
expect(desktopSlashCommandArgumentMode('/lcm')).toBe('text')
})
it('surfaces the shared /wisdom command and hides its legacy install alias', () => {
expect(resolveDesktopCommand('/wisdom')?.surface).toEqual({ kind: 'exec' })
expect(desktopSlashCommandArgumentMode('/wisdom')).toBe('mixed')
expect(isDesktopSlashSuggestion('/wisdom')).toBe(true)
expect(isDesktopSlashCommand('/wisdom')).toBe(true)
expect(isDesktopSlashSuggestion('/collective-wisdom-install')).toBe(false)
expect(isDesktopSlashCommand('/collective-wisdom-install')).toBe(true)
})
it('groups complete.slash rows by backend kind, not the desktop table', () => {
// A registry command the table has never heard of is still a command.
expect(slashCompletionGroup('/refine', 'command')).toBe('Commands')
+9
View File
@@ -1121,6 +1121,9 @@ export interface ProfilesResponse {
export interface SkillInfo {
category: string
description: string
/** Human-facing presentation copy; absent on older backends. */
editorial_description?: string
editorial_name?: string
enabled: boolean
name: string
/** Total observed activity (use + view + patch). Absent on older backends. */
@@ -1134,6 +1137,8 @@ export interface SkillInfo {
export interface OfficialSkillInfo {
category: string
description: string
editorial_description?: string
editorial_name?: string
identifier: string
installed: boolean
name: string
@@ -1591,6 +1596,8 @@ export interface SkillHubSource {
export interface SkillHubResult {
name: string
description: string
editorial_name?: string
editorial_description?: string
source: string
identifier: string
trust_level: string
@@ -1622,6 +1629,8 @@ export interface SkillHubSearchResponse {
export interface SkillHubPreview {
name: string
description: string
editorial_name?: string
editorial_description?: string
source: string
identifier: string
trust_level: string
+9
View File
@@ -113,3 +113,12 @@ export {
type ResolveGatewayWsUrlDeps,
type WebSocketAuthParam
} from './websocket-url'
export {
createWisdomMuteController, initialWisdomMuteState,
type WisdomMuteControl, type WisdomMuteCopy, type WisdomMuteDuration,
type WisdomMuteSnapshot, type WisdomMuteState, type WisdomMuteSync
} from './wisdom-mute'
export {
createWisdomSyncController, initialWisdomSyncView, wisdomSyncCopy,
type WisdomSyncCopy, type WisdomSyncSnapshot, type WisdomSyncState
} from './wisdom-sync'
+224
View File
@@ -0,0 +1,224 @@
export type WisdomMuteDuration = '1_day' | '1_week' | '30_days' | 'forever' | null
export type WisdomMuteSync = 'pending' | 'syncing' | 'synced' | 'failed' | 'conflict' | 'expired'
export interface WisdomMuteSnapshot {
organization_id: string
gateway_available: boolean
mute: null | {
org_id: string
muted: boolean
duration: WisdomMuteDuration
muted_until: string | null
forever: boolean
revision: number
}
sync: null | {
mutation_id: string
requested_duration: WisdomMuteDuration
requested_until: number | null
preference_sync: WisdomMuteSync
request_expires_at: number
}
}
export interface WisdomMuteControl {
id: string
expires_at: number
organization_id: string
mute: NonNullable<WisdomMuteSnapshot['mute']>
sync: WisdomMuteSnapshot['sync']
}
export interface WisdomMuteState {
snapshot: WisdomMuteSnapshot | null
control: WisdomMuteControl | null
choice: WisdomMuteDuration | undefined
busy: boolean
error: boolean
retryingChoice: boolean
}
export const initialWisdomMuteState: WisdomMuteState = {
snapshot: null,
control: null,
choice: undefined,
busy: false,
error: false,
retryingChoice: false
}
export interface WisdomMuteCopy {
title: string
scope: string
on: string
muted: string
day: string
week: string
month: string
forever: string
pending: string
failed: string
conflict: string
expired: string
}
/** Shared state machine: transport retries keep the original native choice. */
export function createWisdomMuteController(deps: {
prepare: () => Promise<WisdomMuteControl>
read: () => Promise<WisdomMuteSnapshot>
choose: (id: string, duration: WisdomMuteDuration) => Promise<WisdomMuteSnapshot>
changed: (state: WisdomMuteState) => void
now?: () => number
}) {
let state = { ...initialWisdomMuteState }
let generation = 0
let disposed = false
let attempt: { id: string; duration: WisdomMuteDuration; org: string } | null = null
const now = deps.now ?? Date.now
const emit = (patch: Partial<WisdomMuteState>) => {
state = { ...state, ...patch }
if (!disposed) {
deps.changed(state)
}
}
const current = (request: number) => !disposed && generation === request
const refresh = async () => {
if (disposed || state.busy) {
return
}
const request = ++generation
attempt = null
emit({ busy: true, error: false, retryingChoice: false, control: null, choice: undefined })
try {
const control = await deps.prepare()
if (!current(request)) {
return
}
if (control.organization_id !== control.mute.org_id || control.expires_at * 1000 <= now()) {
throw new Error('Invalid preference control')
}
emit({
control,
snapshot: {
organization_id: control.organization_id,
gateway_available: true,
mute: control.mute,
sync: control.sync
}
})
} catch {
if (current(request)) {
emit({ error: true })
}
} finally {
if (current(request)) {
emit({ busy: false })
}
}
}
return {
refresh,
select(choice: WisdomMuteDuration) {
if (!disposed && !state.busy && !attempt && state.control) {
emit({ choice })
}
},
async apply() {
if (disposed || state.busy) {
return
}
if (!attempt) {
if (state.choice === undefined || !state.control) {
return
}
if (state.control.expires_at * 1000 <= now()) {
emit({ control: null, error: true, choice: undefined })
return
}
attempt = { id: state.control.id, duration: state.choice, org: state.control.organization_id }
}
const selected = attempt
const request = ++generation
emit({ busy: true, error: false })
try {
const snapshot = await deps.choose(selected.id, selected.duration)
if (!current(request)) {
return
}
if (snapshot.organization_id !== selected.org || (snapshot.mute && snapshot.mute.org_id !== selected.org)) {
throw new Error('Preference identity changed')
}
attempt = null
emit({ snapshot, control: null, choice: undefined, retryingChoice: false, busy: false })
await refresh()
} catch {
if (current(request)) {
emit({ busy: false, error: true, retryingChoice: true })
}
}
},
async poll() {
if (disposed || state.busy || attempt || !state.snapshot) {
return
}
const request = ++generation
try {
const snapshot = await deps.read()
if (!current(request)) {
return
}
if (
snapshot.organization_id !== state.snapshot?.organization_id ||
(snapshot.mute && snapshot.mute.org_id !== snapshot.organization_id)
) {
emit({ snapshot: null, control: null, choice: undefined, error: true })
return
}
if (!snapshot.gateway_available || !snapshot.mute) {
emit({ error: true })
return
}
const stale =
state.control &&
(state.control.mute.revision !== snapshot.mute.revision || state.control.expires_at * 1000 <= now())
emit({ snapshot, error: false, ...(stale ? { control: null, choice: undefined } : {}) })
} catch {
if (current(request)) {
emit({ error: true })
}
}
},
dispose() {
disposed = true
generation++
}
}
}
+64
View File
@@ -0,0 +1,64 @@
export type WisdomSyncState = 'pending' | 'syncing' | 'retryable' | 'conflict' | 'uncertain' | 'waiting_for_receipt'
export type WisdomSyncCounts = Record<WisdomSyncState, number>
export interface WisdomSyncSnapshot {
delivery: WisdomSyncCounts
operation: WisdomSyncCounts
can_retry: boolean
}
export interface WisdomSyncView {
snapshot: WisdomSyncSnapshot | null
busy: boolean
error: boolean
}
export const initialWisdomSyncView: WisdomSyncView = { snapshot: null, busy: false, error: false }
export const wisdomSyncCopy = {
title: 'Receipt and report sync',
delivery: 'Notification receipts',
operation: 'Operation reports',
current: 'Up to date',
retry: 'Retry sync',
unavailable: 'Sync status could not be confirmed.',
scope: 'Sync only. No messages are resent and no skill operations are repeated.',
states: {
pending: 'Waiting to sync',
syncing: 'Syncing',
retryable: 'Retry available',
conflict: 'Conflicting server record; needs investigation',
uncertain: 'Delivery unconfirmed; message will not be resent',
waiting_for_receipt: 'Waiting for notification receipt'
}
}
export type WisdomSyncCopy = typeof wisdomSyncCopy
export function createWisdomSyncController(deps: {
read: () => Promise<WisdomSyncSnapshot>
retry: () => Promise<WisdomSyncSnapshot>
changed: (view: WisdomSyncView) => void
}) {
let disposed = false
let view = initialWisdomSyncView
const run = async (retry: boolean) => {
if (disposed || view.busy || (retry && (!view.snapshot?.can_retry || view.error))) {return}
view = { ...view, busy: true, error: false }
deps.changed(view)
try {
const snapshot = await (retry ? deps.retry() : deps.read())
if (!disposed) {view = { snapshot, busy: false, error: false }}
} catch {
if (!disposed) {view = { ...view, busy: false, error: true }}
}
if (!disposed) {deps.changed(view)}
}
return {
refresh: () => run(false),
retry: () => run(true),
dispose: () => {
disposed = true
}
}
}
+2 -2
View File
@@ -34,6 +34,7 @@ os.environ["HERMES_QUIET"] = "1" # suppress our modules' startup chatter
from hermes_cli.fallback_config import get_fallback_chain
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
from hermes_cli.cli_commands_mixin import CLICommandsMixin
from hermes_cli.cli_wisdom_mixin import CLIWisdomMixin
from hermes_cli.cli_billing_mixin import CLIBillingMixin
from hermes_cli.cli_loops_mixin import CLILoopsMixin
from hermes_cli.cli_info_mixin import CLIInfoMixin
@@ -2527,7 +2528,7 @@ from hermes_cli.cli_chat_turn_mixin import CLIChatTurnMixin
_PASTE_REF_RE = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMixin, CLIStatusBarMixin, CLIVoiceMixin, CLIModelSwitchMixin, CLISessionMixin, CLIStreamMixin, CLIModalMixin, CLITerminalMixin, CLIInfoMixin, CLILoopsMixin, CLIChatTurnMixin):
class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMixin, CLIWisdomMixin, CLIBillingMixin, CLITuiMixin, CLIStatusBarMixin, CLIVoiceMixin, CLIModelSwitchMixin, CLISessionMixin, CLIStreamMixin, CLIModalMixin, CLITerminalMixin, CLIInfoMixin, CLILoopsMixin, CLIChatTurnMixin):
"""Interactive REPL for the Hermes Agent."""
# Seeded -q first message (see _should_seed_interactive); run() re-creates
@@ -3202,7 +3203,6 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix
if callable(getattr(cls, name, None)):
entry = (name, True)
return entry
def process_command(self, command: str) -> bool:
"""Dispatch a slash command; returns False to exit the REPL."""
cmd_lower = command.lower().strip() # lowercase only for matching; args keep their case
+5 -1
View File
@@ -2628,7 +2628,11 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
category), the same set ``/skills list`` shows."""
try:
from tools.skills_tool import _find_all_skills, _sort_skills
skills = _sort_skills(_find_all_skills(skip_disabled=False))
skills = _sort_skills(
_find_all_skills(
skip_disabled=False, include_editorial=True
)
)
except Exception:
logger.exception("GET /v1/skills failed")
return _error_response("Failed to enumerate skills", 500, err_type="server_error")
+25 -2
View File
@@ -2228,9 +2228,9 @@ class BasePlatformAdapter(ABC):
def _is_sender_authorized(self, user_id: Optional[str], chat_type: Optional[str] = None,
chat_id: Optional[str] = None, *, is_bot: bool = False,
thread_id: Optional[str] = None) -> Optional[bool]:
thread_id: Optional[str] = None, command: Optional[str] = None) -> Optional[bool]:
"""True/False from the registered check, or None when no check exists ("trust unknown",
legacy). ``is_bot``/``thread_id`` are forwarded as keywords only when set so legacy
legacy). ``is_bot``/``thread_id``/``command`` are forwarded only when set so legacy
three-positional callbacks keep working. Only literal booleans propagate: a truthy
non-boolean is "unknown", never an authorization that gates a credentialed side effect."""
if not user_id or self._authorization_check is None:
@@ -2240,6 +2240,8 @@ class BasePlatformAdapter(ABC):
extra["is_bot"] = True
if thread_id is not None:
extra["thread_id"] = thread_id
if command is not None:
extra["command"] = command
try:
result = self._authorization_check(user_id, chat_type, chat_id, **extra)
except Exception:
@@ -3441,6 +3443,27 @@ class BasePlatformAdapter(ABC):
task.add_done_callback(self._expected_cancelled_tasks.discard)
return True
async def run_idle_activity(self, session_key: str, callback) -> bool:
"""Run an internal consumer at an idle boundary without a human message.
Wisdom uses the same guard as regular turns. Real messages queue behind
it; stop/reset can cancel it using the ordinary session task registry.
"""
if session_key in self._active_sessions:
return False
guard = asyncio.Event()
task = asyncio.current_task()
self._active_sessions[session_key] = guard
self._session_tasks[session_key] = task
try:
await callback()
return True
finally:
if self._session_tasks.get(session_key) is task:
self._session_tasks.pop(session_key, None)
if self._active_sessions.get(session_key) is guard:
await self._drain_pending_after_session_command(session_key, guard)
async def cancel_session_processing(self, session_key: str, *, release_guard: bool = True,
discard_pending: bool = True) -> None:
"""Cancel in-flight processing for one session. ``release_guard=False`` keeps the guard so
+9 -2
View File
@@ -2115,6 +2115,7 @@ from gateway.run_watchers import GatewaySessionWatchersMixin
from gateway.run_notifications import GatewayNotificationsMixin
from gateway.run_inbound import GatewayInboundMixin
from gateway.run_goals import GatewayGoalsMixin
from gateway.run_wisdom import GatewayWisdomMixin, WisdomCardRefresh, enqueue_weekly_review
from gateway.run_agent_cache import GatewayAgentCacheMixin
from gateway.platforms.base import (
BasePlatformAdapter,
@@ -3280,7 +3281,7 @@ class GatewayRunner(
GatewayVoiceMixin, GatewayAdapterLifecycleMixin, GatewayTopicThreadsMixin, GatewayTurnMixin,
GatewayShutdownMixin, GatewayBusySessionMixin, GatewayConfigLoadersMixin, GatewayStartupMixin,
GatewaySessionWatchersMixin, GatewayNotificationsMixin, GatewayInboundMixin, GatewayGoalsMixin,
GatewayAgentCacheMixin):
GatewayAgentCacheMixin, GatewayWisdomMixin):
"""Main gateway controller: manages adapter lifecycles, routes messages to/from the agent."""
# Class-level defaults so partial construction in tests doesn't blow up on attribute access.
@@ -4067,13 +4068,16 @@ class GatewayRunner(
# fallback. See #210.
team_id = getattr(source, "scope_id", None)
user_id = getattr(source, "user_id", None)
if team_id or user_id:
profile = getattr(source, "profile", None)
if team_id or user_id or profile:
metadata = dict(metadata or {})
if team_id:
metadata["slack_team_id"] = str(team_id)
metadata.setdefault("scope_id", str(team_id))
if user_id:
metadata.setdefault("user_id", str(user_id))
if profile:
metadata.setdefault("profile", str(profile))
from gateway.session_context import source_route_metadata
metadata = source_route_metadata(source, metadata)
# Routed profile for shared state.db namespaces: under profile_routes the transport adapter's
@@ -4566,6 +4570,9 @@ def _start_gateway_housekeeping(
so chores run under any ``CronScheduler`` provider (external scale-to-zero has no 60s loop).
Cadences are ticks of ``interval``; inner gates own the real cadence."""
chores: list[tuple[int, str, Any]] = []
wisdom_cards = WisdomCardRefresh(adapters, loop)
chores.append((1, "Wisdom publication-card refresh", wisdom_cards.tick))
chores.append((60, "Wisdom agent-led review tick", enqueue_weekly_review))
if adapters is not None or runner is not None:
# Restart-safe cron workers run outside the gateway cgroup and queue their final send for
# whichever gateway is live; drained here (not the scheduler tick) so external providers get it too.
+10 -6
View File
@@ -1475,7 +1475,7 @@ class GatewayAdapterLifecycleMixin:
def check(
user_id: str, chat_type: Optional[str] = None, chat_id: Optional[str] = None, *,
is_bot: bool = False, thread_id: Optional[str] = None,
is_bot: bool = False, thread_id: Optional[str] = None, command: Optional[str] = None,
) -> bool:
if not user_id:
return False
@@ -1492,9 +1492,13 @@ class GatewayAdapterLifecycleMixin:
if adapter is not None:
source._transport_adapter_ref = _weakref.ref(adapter)
if transport_home is None:
return self._is_user_authorized(source)
source._authorization_profile_home = transport_home
if not self._stamp_routed_profile(source):
return False # fail-closed, like the ``_handle_message`` ingress gate
return self._is_user_authorized_for_source(source)
allowed = self._is_user_authorized(source)
else:
source._authorization_profile_home = transport_home
if not self._stamp_routed_profile(source):
return False # fail-closed, like the ``_handle_message`` ingress gate
allowed = self._is_user_authorized_for_source(source)
if not allowed:
return False
return self._check_slash_access(source, command) is None if command else allowed
return check
+1 -1
View File
@@ -757,7 +757,7 @@ class GatewayBusySessionMixin:
_PLAIN_COMMANDS = (
"status", "context", "restart", "approve", "deny", "pause", "agents", "bg", "btw",
"kanban", "subgoal", "heartbeat", "busy", "yolo", "verbose", "footer", "help",
"commands", "profile", "login", "update", "version",
"commands", "profile", "wisdom", "login", "update", "version",
)
# Dispatched only on the idle path (busy dispatch has its own allowlist).
_IDLE_COMMANDS = (
+6
View File
@@ -328,6 +328,12 @@ class GatewayGoalsMixin:
await hook(session_entry=session_entry, source=source, final_response=final_text)
except Exception as exc:
logger.debug("%s hook failed: %s", label, exc)
try:
await self._defer_wisdom_candidate_notice_after_delivery(
source, str(session_entry.session_id), user_activity=not is_internal,
)
except Exception as exc:
logger.debug("Wisdom candidate notification hook failed: %s", exc)
@staticmethod
def _final_text_for_post_turn_hooks(agent_result, event=None) -> str:
+2
View File
@@ -781,6 +781,8 @@ class GatewayInboundMixin:
)
async def _hm_cmd_start(self, event, source, _quick_key):
if event.get_command_args().strip().startswith("wisdom_"):
return True, await self._continue_wisdom_start(event, source)
logger.info("Ignoring /start platform ping for session %s", _quick_key)
return True, ""
+10
View File
@@ -327,6 +327,16 @@ class GatewayTurnMixin:
return
session_entry = resolved_entry
self._cache_session_source(session_key, source)
if not bool(getattr(event, "internal", False)):
try:
from gateway.wisdom_mediation import schedule as observe_wisdom_session
await observe_wisdom_session(
self, self._adapter_for_source(source), source,
str(session_entry.session_id), observe_only=True,
)
except Exception:
logger.debug("Wisdom session activity unavailable", exc_info=True)
if await asyncio.to_thread(self._is_telegram_topic_lane, source):
session_entry = await self._hmwa_heal_telegram_topic_binding(source, session_entry, session_key)
from gateway.run_heartbeat_acceptance import resolve_heartbeat_owner
+168
View File
@@ -0,0 +1,168 @@
"""Wisdom integration with gateway command and post-delivery lifecycles."""
import asyncio
import logging
from typing import Any
from gateway.platforms.event import MessageEvent
logger = logging.getLogger("gateway.run")
class GatewayWisdomMixin:
"""Keep Wisdom's service and delivery hooks out of the runner facade."""
async def _handle_wisdom_command(self, event: MessageEvent):
"""Dispatch `/wisdom` through the active profile's shared service."""
source = event.source
adapter = self._adapter_for_source(source)
raw_args = event.get_command_args()
if event.get_command() == "collective-wisdom-install":
raw_args = f"install {raw_args}".strip()
rich_handler = getattr(adapter, "send_wisdom_command", None)
if callable(rich_handler):
await rich_handler(raw_args, source=source)
return ""
from gateway.wisdom_command import (
WisdomCommandContext,
WisdomCommandController,
)
from hermes_wisdom.service import WisdomService
from gateway.run import _profile_runtime_scope
profile_home = self._resolve_profile_home_for_source(source)
def command_action():
with _profile_runtime_scope(profile_home):
service = WisdomService()
context = WisdomCommandContext(
user_id=str(source.user_id or ""),
chat_id=str(source.chat_id),
profile=getattr(source, "profile", None),
organization_id=service.store.active_org_id(),
is_group=str(source.chat_type or "").lower()
in {"group", "supergroup", "channel", "forum"},
)
return WisdomCommandController().execute(
raw_args, service, context
)
try:
view = await asyncio.to_thread(command_action)
except Exception as exc:
logger.warning(
"Collective Wisdom command failed (%s)", type(exc).__name__
)
from gateway.wisdom_command import command_error_text
return f"Collective Wisdom could not continue: {command_error_text(exc)}"
return view.to_text()
async def _defer_wisdom_candidate_notice_after_delivery(
self, source: Any, session_id: str, *, user_activity: bool = True
) -> None:
"""Surface local qualification after the originating client reply."""
adapter = self._adapter_for_source(source)
sender = getattr(adapter, "send_wisdom_candidate_notifications", None)
if adapter is None or not callable(sender):
return
try:
metadata = self._thread_metadata_for_source(source)
except Exception:
metadata = None
async def _deliver() -> None:
try:
if user_activity:
from gateway.wisdom_mediation import schedule
if await schedule(self, adapter, source, session_id):
return
await sender(
source.chat_id,
session_id,
metadata=metadata,
)
except Exception as exc:
logger.warning(
"Wisdom candidate %s delivery failed: %s",
getattr(source, "platform", "platform"),
exc,
exc_info=True,
)
try:
session_key = self._session_key_for_source(source)
except Exception:
session_key = None
if session_key and hasattr(adapter, "register_post_delivery_callback"):
try:
generation = None
active = getattr(adapter, "_active_sessions", {}).get(session_key)
if active is not None:
generation = getattr(active, "_hermes_run_generation", None)
adapter.register_post_delivery_callback(
session_key,
_deliver,
generation=generation,
)
return
except Exception as exc:
logger.debug(
"Wisdom candidate post-delivery callback registration failed: %s",
exc,
)
await _deliver()
async def _continue_wisdom_start(self, event, source) -> str:
denied = self._check_slash_access(source, "wisdom")
if denied is not None:
return denied
adapter = self._adapter_for_source(source)
continuation = getattr(adapter, "send_wisdom_continuation", None)
if callable(continuation):
try:
token = event.get_command_args().strip().removeprefix("wisdom_")
await continuation(token, source=source)
except (PermissionError, ValueError) as exc:
return str(exc)
except Exception as exc:
logger.warning("Collective Wisdom DM continuation failed (%s)", type(exc).__name__)
return (
"Collective Wisdom could not continue that request. "
"Run /wisdom in this chat instead."
)
return ""
def enqueue_weekly_review() -> None:
"""Queue local evidence; only the active private session assesses and delivers."""
from hermes_wisdom.service import WisdomService
from hermes_wisdom.weekly_queue import enqueue_weekly_review as enqueue
enqueue(WisdomService())
class WisdomCardRefresh:
"""Keep housekeeping from starting overlapping publication-card refreshes."""
def __init__(self, adapters, loop):
self.adapters = adapters
self.loop = loop
self.pending = None
def tick(self) -> None:
if not self.adapters or self.loop is None:
return
if self.pending is not None:
if not self.pending.done():
return
try:
self.pending.result()
except Exception:
logger.debug("Wisdom publication-card refresh failed", exc_info=True)
from gateway.wisdom_publication_cards import refresh
self.pending = asyncio.run_coroutine_threadsafe(refresh(self.adapters), self.loop)
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
"""Bind command reviews to the existing session and saved approval state."""
from gateway.platforms.event import MessageEvent
from gateway.session import SessionSource
def surface_context(adapter, *, user_id, chat_id, profile, organization_id,
is_group=False, thread_id="", scope_id=""):
from gateway.wisdom_command import WisdomCommandContext
source = SessionSource(
platform=adapter.platform, user_id=user_id, chat_id=chat_id,
profile=profile, chat_type="group" if is_group else "dm",
thread_id=thread_id or None, scope_id=scope_id or None,
)
return WisdomCommandContext(
user_id=user_id, chat_id=chat_id, profile=profile,
organization_id=organization_id, is_group=is_group,
thread_id=thread_id, scope_id=scope_id,
platform=adapter.platform.value,
session_key=adapter._event_session_key(MessageEvent(text="", source=source)),
)
def command_review(service, plan, context):
from hermes_wisdom.consent import ConsentActor, WisdomConsent
from hermes_wisdom.mediation_view import interaction_view
local = context.chat_id.startswith("local:") and not context.platform
platform = "local" if local else context.platform
session_key = context.chat_id.removeprefix("local:") if local else context.session_key
if (context.is_group or not context.user_id or not session_key
or platform not in {"local", "telegram", "slack"}
or (local and context.user_id != "local-user")):
raise PermissionError("Open a private session to review this installation.")
org = service.store.active_org_id()
if org != context.organization_id:
raise PermissionError("The active organization changed. Reopen the review.")
consent = WisdomConsent(service)
actor = ConsentActor(session_key, platform, context.user_id, context.chat_id,
context.thread_id, context.scope_id)
# Commands establish ownership, not eligibility for background notifications.
with service.store.transaction() as db:
consent.queue._check_org(db, org)
existing = db.execute(
"SELECT 1 FROM wisdom_agent_session WHERE organization_id=? AND session_key=?",
(org, session_key),
).fetchone()
if not existing:
consent.queue.register_session(
org, session_key=session_key, session_id=session_key,
platform=platform, actor_id=actor.actor_id, private=True,
available=False, address=actor.address,
)
result = consent.request(
org, {"kind": "skill", "skill_id": plan["skill_id"],
"version": plan["version"], "update_mode": plan.get("update_mode")},
actor, title=str(plan.get("slug") or plan["skill_id"]),
explanation="You requested this exact package review.", queue_delivery=False,
)
return interaction_view(result)
+242
View File
@@ -0,0 +1,242 @@
"""Wisdom's consumer of the messaging gateway's idle-session scheduling rail."""
from __future__ import annotations
import asyncio
import logging
import time
from gateway.wisdom_command import WisdomCommandContext, bind_view_callbacks
from hermes_wisdom.consent import ConsentActor
from hermes_wisdom.mediation import WisdomMediation, delivery_mode, session_runtime
from hermes_wisdom.mediation_view import advice_view, delivery_groups
logger = logging.getLogger(__name__)
ACTIVE_SECONDS = 10 * 60
async def schedule(
gateway, adapter, source, session_id: str, *, observe_only: bool = False
) -> bool:
"""Return whether agent mode owns notification delivery for this profile."""
platform = str(getattr(source.platform, "value", source.platform))
profile = getattr(source, "profile", None) or getattr(
adapter, "_owner_profile", None
)
if not callable(getattr(adapter, "_run_wisdom_profile_operation", None)):
return False
async def scoped(fn):
if platform == "slack":
return await adapter._run_wisdom_profile_operation(fn, profile=profile)
return await adapter._run_wisdom_profile_operation(fn)
mediated = await scoped(delivery_mode) == "agent"
if platform not in {"telegram", "slack"}:
return mediated
if str(getattr(source, "chat_type", "")) not in {"dm", "private"}:
return mediated
if not getattr(source, "user_id", None) or not gateway._is_user_authorized(source):
return mediated
key = gateway._session_key_for_source(source)
actor = ConsentActor(
key,
platform,
str(source.user_id),
str(source.chat_id),
str(getattr(source, "thread_id", None) or ""),
str(getattr(source, "scope_id", None) or ""),
)
from hermes_wisdom.service import WisdomService
def register():
from hermes_wisdom.entitlement import local_work_allowed
service = WisdomService()
service.require_setup()
if not local_work_allowed(service.store):
raise PackagePolicyError("Wisdom entitlement unavailable")
org = service.store.active_org_id()
mediation = WisdomMediation(service)
mediation.queue.register_session(
org,
session_key=key,
session_id=session_id,
platform=platform,
actor_id=actor.actor_id,
private=True,
available=False,
user_activity=observe_only,
address=actor.address,
)
from hermes_wisdom.package import PackagePolicyError
try:
await scoped(register)
except PackagePolicyError:
# Fixed notification delivery still owns the unconfigured profile path.
return mediated
if observe_only:
return mediated
tasks = getattr(gateway, "_wisdom_mediation_tasks", None)
if tasks is None:
tasks = gateway._wisdom_mediation_tasks = {}
deadlines = getattr(gateway, "_wisdom_mediation_active_until", None)
if deadlines is None:
deadlines = gateway._wisdom_mediation_active_until = {}
deadlines[key] = time.monotonic() + ACTIVE_SECONDS
if key in tasks and not tasks[key].done():
return mediated
async def tick():
from tools.approval import get_pending_gateway_approval
from tools.clarify_gateway import has_pending
if get_pending_gateway_approval(key) or has_pending(key):
return
with gateway._agent_cache_lock:
cached = gateway._agent_cache.get(key)
agent = cached[0] if isinstance(cached, tuple) else cached
runtime = session_runtime(agent)
history = list(getattr(agent, "_session_messages", None) or [])
def prepare():
from hermes_wisdom.entitlement import local_work_allowed
service = WisdomService()
mediation = WisdomMediation(service)
org = service.store.active_org_id()
if not local_work_allowed(service.store):
return org, []
mediation.queue.register_session(
org,
session_key=key,
session_id=session_id,
platform=platform,
actor_id=actor.actor_id,
private=True,
available=True,
address=actor.address,
)
if mediation.queue.claim_refresh(org):
try:
service.check(apply_automatic=False)
mediation.ingest()
except Exception as exc:
logger.debug(
"Wisdom feed refresh deferred (%s)", type(exc).__name__
)
return org, mediation.prepare(org, actor, runtime=runtime, history=history)
org, items = await scoped(prepare)
if not items:
return
guard = adapter._active_sessions.get(key)
if guard is None or guard.is_set():
return
def begin(group):
mediation = WisdomMediation(WisdomService())
introduction = not mediation.queue.introduced(org)
selected = mediation.begin_delivery(org, group)
return selected, introduction
for group in delivery_groups(items):
selected, introduction = await scoped(lambda: begin(group))
if not selected:
continue
guard = adapter._active_sessions.get(key)
ready = await scoped(
lambda: WisdomMediation(WisdomService()).delivery_ready(org, selected)
)
if (
guard is None
or guard.is_set()
or not gateway._is_user_authorized(source)
or get_pending_gateway_approval(key)
or has_pending(key)
or not ready
):
await scoped(
lambda: WisdomMediation(WisdomService()).cancel_delivery(
org, selected
)
)
continue
try:
view = advice_view(selected, introduction=introduction)
bind_view_callbacks(view, WisdomCommandContext(
user_id=actor.actor_id, chat_id=actor.chat_id,
profile=profile, organization_id=org,
thread_id=actor.thread_id, scope_id=actor.scope_id,
))
except Exception:
await scoped(
lambda: WisdomMediation(WisdomService()).cancel_delivery(
org, selected
)
)
raise
def uncertain():
mediation = WisdomMediation(WisdomService())
for item in selected:
job = item["assessment"]
mediation.queue.uncertain_delivery(
org, job["id"], job["lease_token"]
)
try:
receipt = await adapter.send_wisdom_mediation(view, source=source)
def finish():
mediation = WisdomMediation(WisdomService())
for item in selected:
job = item["assessment"]
mediation.queue.complete_delivery(
org,
job["id"],
job["lease_token"],
receipt=receipt,
introduced=introduction,
)
await scoped(finish)
except BaseException:
# Also fence cancellation after an external send. If persistence
# fails, lease expiry still prevents automatic replay.
try:
await scoped(uncertain)
except Exception:
logger.warning("Wisdom uncertain delivery awaits lease recovery")
raise
async def watch():
try:
# The post-delivery hook still owns its guard until it returns.
await asyncio.sleep(1)
while time.monotonic() < deadlines.get(key, 0):
if not gateway._is_user_authorized(source):
return
try:
await adapter.run_idle_activity(key, tick)
except Exception as exc:
logger.warning(
"Wisdom session mediation deferred (%s)", type(exc).__name__
)
await asyncio.sleep(60)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Wisdom session mediation paused (%s)", type(exc).__name__)
finally:
if tasks.get(key) is asyncio.current_task():
tasks.pop(key, None)
deadlines.pop(key, None)
task = asyncio.create_task(watch())
tasks[key] = task
adapter._background_tasks.add(task)
task.add_done_callback(adapter._background_tasks.discard)
return mediated
+49
View File
@@ -0,0 +1,49 @@
"""Edit moderation cards on the gateway loop, without starting an agent turn."""
import asyncio
import logging
logger = logging.getLogger(__name__)
async def refresh(adapters):
from hermes_wisdom.publication_cards import PublicationCards
from hermes_wisdom.service import WisdomService
for adapter in adapters.values():
edit = getattr(adapter, "edit_wisdom_publication", None)
scoped = getattr(adapter, "_run_wisdom_profile_operation", None)
if not callable(edit) or not callable(scoped):
continue
platform = str(getattr(adapter.platform, "value", adapter.platform))
try:
jobs = await scoped(
lambda: PublicationCards(WisdomService()).claim(platform)
)
for job in jobs:
success = False
try:
await asyncio.wait_for(
edit(job["receipt"], job["view"]), timeout=30
)
success = True
logger.info(
"Wisdom publication card updated: platform=%s message=%s state=%s",
platform,
job["receipt"]["message_id"],
job["state"],
)
except Exception as exc:
logger.warning(
"Wisdom publication card edit deferred (%s)", type(exc).__name__
)
finally:
await scoped(
lambda: PublicationCards(WisdomService()).finish(
job, success=success
)
)
except Exception as exc:
logger.debug(
"Wisdom publication card refresh deferred (%s)", type(exc).__name__
)
+8 -5
View File
@@ -181,27 +181,30 @@ def _lease_paths(
return home / "runtime" / "active_sessions.json", home / "runtime" / "active_sessions.lock"
def _flock(fh, *, lock: bool) -> None:
def _flock(fh, *, lock: bool, blocking: bool = True) -> None:
"""Exclusive whole-file lock/unlock on ``fh`` (fcntl on POSIX, msvcrt on Windows)."""
if os.name == "nt":
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK if lock else msvcrt.LK_UNLCK, 1)
mode = (msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK) if lock else msvcrt.LK_UNLCK
msvcrt.locking(fh.fileno(), mode, 1)
else:
import fcntl
fcntl.flock(fh.fileno(), fcntl.LOCK_EX if lock else fcntl.LOCK_UN)
mode = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) if lock else fcntl.LOCK_UN
fcntl.flock(fh.fileno(), mode)
class _FileLock:
def __init__(self, path: Path):
def __init__(self, path: Path, *, blocking: bool = True):
self.path = path
self.blocking = blocking
self._fh = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fh = open(self.path, "a+b")
try:
_flock(self._fh, lock=True)
_flock(self._fh, lock=True, blocking=self.blocking)
except Exception as exc:
self._fh.close()
self._fh = None
+7 -1
View File
@@ -1222,7 +1222,13 @@ def clear_provider_auth(provider_id: Optional[str] = None) -> bool:
cleared = True
if cleared:
_save_auth_store(auth_store)
return cleared
if target == "nous":
from hermes_wisdom.account_session import sign_out
# Also retire queued work if credentials were already removed. Do this
# outside the auth lock: Wisdom workers can resolve auth during DB work.
cleared = sign_out() or cleared
return cleared
def deactivate_provider() -> None:
+3 -1
View File
@@ -238,7 +238,9 @@ class CLIInfoMixin:
def _command_available(self, slash_command: str) -> bool:
if slash_command == "/fast":
return self._fast_command_available()
return True
from hermes_cli.commands import command_available, resolve_command
command = resolve_command(slash_command)
return command is None or command_available(command)
def show_help(self, arg: str = ""):
"""Display help. Bare /help shows categorized core commands with the skill list collapsed
+35
View File
@@ -0,0 +1,35 @@
"""Interactive CLI adapter for the shared Collective Wisdom command flow."""
class CLIWisdomMixin:
def _handle_wisdom_command(self, cmd_original: str) -> None:
from gateway.wisdom_command import (
WisdomCommandContext,
WisdomCommandController,
command_error_text,
render_local_view,
)
from hermes_constants import get_hermes_home
from hermes_wisdom.entitlement import require_entitlement
from hermes_wisdom.service import WisdomService
parts = cmd_original.split(None, 1)
invoked_as = parts[0].lstrip("/").lower() if parts else "wisdom"
raw_args = parts[1].strip() if len(parts) > 1 else ""
if invoked_as == "collective-wisdom-install":
raw_args = f"install {raw_args}".strip()
try:
require_entitlement()
service = WisdomService()
context = WisdomCommandContext(
user_id="local-user",
chat_id=f"local:{getattr(self, 'session_id', '')}",
profile=str(get_hermes_home()),
organization_id=service.store.active_org_id(),
is_group=False,
)
view = WisdomCommandController().execute(raw_args, service, context)
print(render_local_view(view, context))
except Exception as exc:
print(f"Collective Wisdom could not continue: {command_error_text(exc)}")
+71 -2
View File
@@ -9,7 +9,8 @@ from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Mapping
from utils import is_truthy_value
from hermes_constants import INDICATOR_STYLES
@@ -41,11 +42,42 @@ class CommandDef:
argument_mode: str | None = None # desktop composer: options|text|mixed; None inferred
# Desktop availability: None = offered; "hidden" = runs but out of the popover; else a reason.
desktop: str | None = None
# Optional help copy for static subcommands. Completion surfaces display it
# beside the subcommand name; callers that only understand ``subcommands``
# continue to receive the existing flat tuple.
subcommand_descriptions: Mapping[str, str] = field(default_factory=dict)
VALID_BUSY_POLICIES: frozenset[str] = frozenset({"dispatch", "reject", "interrupt_then_dispatch"})
WISDOM_SUBCOMMAND_HELP: dict[str, str] = {
"setup": "Configure this profile for Collective Wisdom",
"status": "Show account, organization, setup, and Gateway health",
"browse": "[query] — Search skills published by your team",
"show": "<skill> — View its description, requirements, scan, and install state",
"versions": "<skill> — Browse immutable published versions",
"candidates": "[all|query] — Review qualified or manually eligible local skills",
"submit": "<local-skill> — Prepare an owner-private contribution draft",
"drafts": "List your drafts and moderation states",
"review": "<draft> — Review scans, policy, hashes, and available actions",
"install": "<id|URL|id@vN> — Plan and confirm a managed installation",
"installed": "List and manage skills installed on this device",
"check": "Check installed skills and apply eligible automatic updates",
"update": "<skill|all> — Plan and confirm available updates",
"uninstall": "<skill> — Remove a managed skill after confirmation",
"notifications": "Review unseen publication, install, and update events",
"mute": "[status|1d|1w|30d|forever|off] — Manage your organization's proactive notifications",
"sync": "[status|retry] — Check or retry saved notification receipts and operation reports",
"inbox": "Read shared agent advice and pending Wisdom consent",
"consent": "<id> <inspect|defer|confirm> — Use an exact pending consent control in the local CLI",
"help": "Show this guide and command examples",
}
# ---------------------------------------------------------------------------
# Central registry -- single source of truth
# ---------------------------------------------------------------------------
COMMAND_REGISTRY: list[CommandDef] = [
# Session
CommandDef("start", "Acknowledge platform start pings without a reply", "Session",
@@ -217,6 +249,17 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "approval"),
desktop="settings"),
CommandDef(
"wisdom",
"Browse, contribute, install, and manage Collective Wisdom skills",
"Tools & Skills",
aliases=("collective-wisdom-install",),
args_hint="[keyword]",
subcommands=tuple(WISDOM_SUBCOMMAND_HELP),
subcommand_descriptions=WISDOM_SUBCOMMAND_HELP,
busy_policy="reject",
argument_mode="mixed",
),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"Tools & Skills", args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
@@ -335,6 +378,25 @@ def resolve_command(name: str) -> CommandDef | None:
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
def command_available(command: CommandDef | str) -> bool:
"""Return whether a registry command may be presented or dispatched locally.
The registry and lookup tables deliberately remain static. Availability is a
fresh, refresh-free projection so login/logout and token replacement take effect
without mutating command state held by a running conversation.
"""
cmd = command if isinstance(command, CommandDef) else resolve_command(command)
if cmd is None:
return False
if cmd.name != "wisdom":
return True
try:
from hermes_wisdom.entitlement import is_entitled
return bool(is_entitled())
except Exception:
return False
def _build_description(cmd: CommandDef) -> str:
"""CLI-facing description including the usage hint."""
if not cmd.args_hint:
@@ -349,6 +411,10 @@ COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
# registry order), then pipe patterns in args_hint ("[on|off|status]") as fallback.
SUBCOMMANDS: dict[str, list[str]] = {
f"/{_cmd.name}": list(_cmd.subcommands) for _cmd in COMMAND_REGISTRY if _cmd.subcommands}
SUBCOMMAND_DESCRIPTIONS: dict[str, dict[str, str]] = {
f"/{cmd.name}": {sub: str(cmd.subcommand_descriptions.get(sub) or "") for sub in cmd.subcommands}
for cmd in COMMAND_REGISTRY if cmd.subcommands
}
for _cmd in COMMAND_REGISTRY:
if _cmd.gateway_only:
continue
@@ -433,7 +499,10 @@ def _resolve_config_gates() -> set[str]:
def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = None) -> bool:
"""Not ``cli_only``, or its config gate is truthy (*config_overrides* from
``_resolve_config_gates()`` avoids re-reading config per command)."""
``_resolve_config_gates()`` avoids re-reading config per command), and passes
any refresh-free account entitlement gate."""
if not command_available(cmd):
return False
if not cmd.cli_only:
return True
if not cmd.gateway_config_gate:
+8 -2
View File
@@ -16,7 +16,7 @@ from typing import Any, Dict, Optional, Tuple
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
from prompt_toolkit.completion import Completer, Completion
from hermes_cli.commands import COMMANDS, SUBCOMMANDS
from hermes_cli.commands import COMMANDS, SUBCOMMANDS, SUBCOMMAND_DESCRIPTIONS
# (config-file signature, personalities) memo for /personality completion.
_personalities_memo: Optional[
@@ -281,6 +281,11 @@ class SlashCommandCompleter(Completer):
self._file_cache_cwd: str = ""
def _command_allowed(self, slash_command: str) -> bool:
from hermes_cli.commands import command_available, resolve_command
command = resolve_command(slash_command)
if command is not None and not command_available(command):
return False
try:
return self._command_filter is None or bool(self._command_filter(slash_command))
except Exception:
@@ -435,7 +440,8 @@ class SlashCommandCompleter(Completer):
yield from handler(sub_text, sub_text.lower())
elif first_arg and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd):
yield from _prefix_completions(
((s, None) for s in SUBCOMMANDS[base_cmd]), sub_text)
((s, SUBCOMMAND_DESCRIPTIONS.get(base_cmd, {}).get(s))
for s in SUBCOMMANDS[base_cmd]), sub_text)
return
word = text[1:]
+2 -2
View File
@@ -96,7 +96,7 @@ _TELEGRAM_PRIORITY_TIERS: dict[str, tuple[str, ...]] = {
# Built-ins that must survive Telegram's small visible menu cap (everything else stays
# dispatchable when typed). Order = rank: everyday, maintenance, mid-turn control, operational.
_TELEGRAM_MENU_PRIORITY = (
"help", "new", "stop", "status", "egress", "resume", "sessions", "model",
"help", "new", "stop", "status", "egress", "resume", "sessions", "model", "wisdom",
"debug", "restart", "update", "verbose", "commands",
"approve", "deny", "queue", "steer", "bg", "btw",
"reasoning", "usage", "platforms", "platform", "profile", "whoami")
@@ -368,7 +368,7 @@ _SLACK_RESERVED_COMMANDS = frozenset({
# parity test reads this set. Aliases are never pinned ahead of canonicals.
_SLACK_VIA_HERMES_ONLY = frozenset({
"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat",
"refine", "review", "pause", "whoami", "platform", "insights", "login"})
"refine", "review", "pause", "whoami", "platform", "insights", "start", "login"})
def _sanitize_slack_name(raw: str) -> str:
+30
View File
@@ -1358,6 +1358,36 @@ DEFAULT_CONFIG = {
# See #79686.
"ledger": True,
},
# Collective Wisdom — local qualification plus owner-consented sharing.
# The sync.base_url transport and existing Nous OAuth token are reused;
# no Gateway secret or URL is exposed to renderer clients.
"wisdom": {
"enabled": False,
"portal_url": "https://portal.nousresearch.com",
"request_timeout": 30,
"notifications": {
"delivery_mode": "agent", # Use fixed to opt out of agent-written advice.
"decisions": "immediate",
"installed_updates": "immediate",
"new_skills": "daily",
},
# Agent-led sharing: a weekly agent review of real 7-day usage
# proposes at most a few bespoke skills to share, with fixed copy and
# native buttons. When disabled, only the deterministic qualification
# triggers (consecutive-day / refinement) surface candidates.
# Active only with notifications.delivery_mode: agent. Server policy
# controls eligibility and frequency, never the local rollout choice.
"agent_led": {
"window_days": 7,
"min_aggregate_count": 3,
"max_candidates": 3,
"dismiss_suppression_days": 30,
"popular_install_threshold": 10,
"review_interval_hours": 24 * 7,
},
},
# Curator — background maintenance of AGENT-CREATED skills (never hub-installed): marks
# long-unused skills stale, archives (never deletes) obsolete ones, optionally consolidates
# overlaps via a forked aux-model agent. Inactivity-triggered from session start, no cron
+3 -1
View File
@@ -318,6 +318,7 @@ from typing import Optional
from hermes_cli.subcommands.cron import build_cron_parser
from hermes_cli.subcommands.sync import build_sync_parser
from hermes_cli.subcommands.wisdom import build_wisdom_parser
from hermes_cli.subcommands.gateway import build_gateway_parser
from hermes_cli.subcommands.profile import build_profile_parser
from hermes_cli.subcommands.model import build_model_parser
@@ -2642,7 +2643,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"prompt-size",
"resume",
"send", "sessions", "setup",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update", "wisdom",
"vault",
"webhook", "whatsapp", "whatsapp-cloud", "worktree", "chat", "secrets", "security",
"browser",
@@ -3241,6 +3242,7 @@ def _build_cli_parser():
build_pause_parser(subparsers)
build_cron_parser(subparsers, cmd_cron=cmd_cron)
build_sync_parser(subparsers, cmd_sync=cmd_sync)
build_wisdom_parser(subparsers)
build_webhook_parser(subparsers, cmd_webhook=cmd_webhook)
from hermes_cli.subcommands.peer import build_peer_parser
+7 -2
View File
@@ -164,7 +164,7 @@ def _blank_slate_minimal_toolsets(config: dict):
keep = {"file", "terminal", "vision", "skills"}
config.setdefault("platform_toolsets", {})["cli"] = sorted(keep)
try:
from toolsets import TOOLSETS
from toolsets import TOOLSETS, resolve_toolset
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_plugin_toolset_keys
all_keys = {k for k, _, _ in CONFIGURABLE_TOOLSETS}
all_keys.update(_get_plugin_toolset_keys())
@@ -178,7 +178,12 @@ def _blank_slate_minimal_toolsets(config: dict):
# here causes model_tools to subtract their tools (terminal, read_file, …) from the minimal
# Blank Slate surface (#57315).
all_keys.add(k)
disabled = sorted(all_keys - keep)
# Disabling a bundle subtracts its tools even if another kept bundle owns them.
kept_tools = {tool for name in keep for tool in resolve_toolset(name)}
disabled = sorted(
name for name in all_keys - keep
if kept_tools.isdisjoint(resolve_toolset(name))
)
if disabled:
config.setdefault("agent", {})["disabled_toolsets"] = disabled
except Exception as exc:
+424
View File
@@ -0,0 +1,424 @@
"""`hermes wisdom` command surface."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Callable
def _emit(value: Any, *, as_json: bool) -> None:
if as_json:
print(
json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False, default=str)
)
elif isinstance(value, (dict, list)):
print(json.dumps(value, indent=2, ensure_ascii=False, default=str))
else:
print(value)
def cmd_wisdom(args: argparse.Namespace) -> int:
from hermes_wisdom.client import WisdomError
from hermes_wisdom.entitlement import require_entitlement
from hermes_wisdom.package import PackagePolicyError
from hermes_wisdom.service import WisdomService
command = getattr(args, "wisdom_command", None)
try:
require_entitlement()
service = WisdomService()
if command not in {"setup", "status", None}:
service.require_setup()
if command == "setup":
accepted = bool(args.accept_disclosure)
if not accepted:
if not sys.stdin.isatty():
raise PackagePolicyError(
"noninteractive setup requires --accept-disclosure"
)
from hermes_wisdom.service import WISDOM_DISCLOSURE
print(WISDOM_DISCLOSURE)
answer = input("Enable Collective Wisdom for this profile? [y/N] ")
if answer.strip().lower() not in {"y", "yes"}:
return 7
accepted = True
result = service.setup(disclosure_accepted=accepted)
elif command == "status":
result = service.status()
elif command == "installed-setup":
from hermes_wisdom.installed_setup import inspect_installed_setup
result = inspect_installed_setup(service.store, args.skill_id, version=args.version)
elif command == "sync":
result = service.retry_sync() if args.action == "retry" else service.sync_status()
elif command == "inbox":
from hermes_wisdom.mediation import WisdomMediation
result = WisdomMediation(service).activity()
elif command == "consent":
from hermes_wisdom.consent import ConsentActor, WisdomConsent
org = service.store.active_org_id()
with service.store.transaction() as db:
row = db.execute(
"SELECT owner_session,platform FROM wisdom_consent WHERE organization_id=? AND id=?",
(org, args.interaction_id),
).fetchone()
if row is None or row["platform"] != "local":
raise PackagePolicyError("Use this interaction's original native control")
actor = ConsentActor(row["owner_session"], "local", "local-user", f"local:{row['owner_session']}")
consent = WisdomConsent(service)
preview = consent.resolve(org, args.interaction_id, actor, "inspect")
if args.action == "confirm":
if not sys.stdin.isatty():
raise PackagePolicyError("Wisdom consent requires an interactive native confirmation")
_emit(preview, as_json=False)
if input("Apply this exact Wisdom operation? [y/N] ").strip().lower() not in {"y", "yes"}:
return 7
result = consent.resolve(org, args.interaction_id, actor, args.action)
elif command == "scan":
result = service.scan(getattr(args, "skill", None))
elif command == "suggest":
raw_specification = getattr(args, "system_specification", None)
system_specification = None
if raw_specification:
try:
system_specification = json.loads(raw_specification)
except json.JSONDecodeError as exc:
raise PackagePolicyError(
"--system-specification-json must be valid JSON"
) from exc
result = service.suggest(
getattr(args, "skill", None),
description=getattr(args, "description", None),
system_specification=system_specification,
allow_private_secret_review=getattr(
args, "private_secret_override", False
),
)
elif command == "candidates":
result = {"candidates": service.scan_candidates()}
elif command == "review":
if not args.portal and not args.acknowledge and not sys.stdin.isatty():
raise PackagePolicyError(
"noninteractive review cannot create consent; use --portal or an interactive --acknowledge"
)
acknowledge = bool(args.acknowledge)
if not args.portal and not acknowledge:
preview = service.review(args.draft_id, acknowledge=False)
_emit(preview, as_json=args.json)
answer = input(
"Review every raw file and the three hashes above. Record consent receipt? [y/N] "
)
if answer.strip().lower() not in {"y", "yes"}:
return 7
acknowledge = True
result = service.review(
args.draft_id, acknowledge=acknowledge, portal=args.portal
)
elif command == "approve":
result = service.approve(args.draft_id)
elif command == "decline":
result = service.decline(args.draft_id)
elif command == "list":
result = service.list_skills()
elif command == "show":
result = service.show(args.skill_id)
elif command == "versions":
result = {"versions": service.versions(args.skill_id)}
elif command == "install":
if args.apply_receipt:
result = service.install_apply(
args.apply_receipt, accept_partial=args.accept_partial
)
else:
plan = service.install_plan(
args.reference, update_mode=args.update_mode
)
if args.plan or args.json:
result = plan
else:
_emit(plan, as_json=False)
if not sys.stdin.isatty():
raise PackagePolicyError(
"noninteractive install requires --plan then --apply-receipt"
)
answer = input("Apply this authenticated install plan? [y/N] ")
if answer.strip().lower() not in {"y", "yes"}:
return 7
result = service.install_apply(
plan["receipt"], accept_partial=args.accept_partial
)
elif command == "check":
result = service.check(apply_automatic=True)
elif command == "update":
if args.all:
result = service.update_all(apply=True)
elif args.apply_receipt:
result = service.update_apply(
args.apply_receipt,
accept_sensitive=args.accept_sensitive,
accept_partial=args.accept_partial,
preserve_modified=args.preserve_modified,
)
elif args.skill_id:
plan = service.update_plan(args.skill_id)
if plan.get("state") == "current" or args.plan or args.json:
result = plan
else:
_emit(plan, as_json=False)
if not sys.stdin.isatty():
raise PackagePolicyError(
"noninteractive update requires --plan then --apply-receipt"
)
answer = input("Apply this verified managed update plan? [y/N] ")
if answer.strip().lower() not in {"y", "yes"}:
return 7
result = service.update_apply(
plan["receipt"],
accept_sensitive=args.accept_sensitive,
accept_partial=args.accept_partial,
preserve_modified=args.preserve_modified,
)
else:
raise PackagePolicyError("update requires a skill id or --all")
elif command == "uninstall":
if not args.yes:
if not sys.stdin.isatty():
raise PackagePolicyError("noninteractive uninstall requires --yes")
answer = input(
"Move this managed skill to recoverable Wisdom trash? [y/N] "
)
if answer.strip().lower() not in {"y", "yes"}:
return 7
result = service.uninstall(args.skill_id)
elif command == "notifications":
result = service.notifications(mark_seen=args.mark_seen)
elif command == "browse":
result = {"skills": service.search_skills(getattr(args, "query", None))}
elif command == "review-week":
from hermes_wisdom.weekly_queue import enqueue_weekly_review
result = enqueue_weekly_review(service, dry_run=bool(getattr(args, "dry_run", False)))
elif command == "act":
from hermes_wisdom.agent_led.actions import handle_action
result = handle_action(
args.target, service=service, mute_choice=getattr(args, "mute", None)
)
elif command == "share":
from hermes_wisdom.agent_led.share_flow import ShareFlow
flow = ShareFlow(getattr(args, "flow_id", None))
if args.share_step == "start":
from tools.skill_usage import _find_skill_dir
path = _find_skill_dir(args.skill)
if path is None:
raise PackagePolicyError(f"skill not found: {args.skill}")
result = flow.start(path)
elif args.share_step == "package":
result = flow.package()
elif args.share_step == "request-changes":
result = flow.request_changes(args.note or "")
elif args.share_step == "cancel":
result = flow.cancel()
elif args.share_step == "approve":
from hermes_wisdom.agent_led.share_flow import submit_via_service
from hermes_constants import get_hermes_home
staging = Path(get_hermes_home()) / "wisdom" / "share_staging"
result = flow.approve(submit=submit_via_service(service, staging))
else:
result = flow.summary()
elif command == "dismiss":
from hermes_wisdom.agent_led.history import SuggestionHistory
from hermes_wisdom.agent_led.policy import load_policy
policy = load_policy(client=service.client)
result = SuggestionHistory().record_dismissal(
args.skill,
args.content_hash,
suppression_days=args.days or policy.dismiss_suppression_days,
client=service.client,
)
elif command == "mute":
from hermes_wisdom.preferences import WisdomPreferences
result = WisdomPreferences(service).native_mute_command(args.duration)
else:
args._wisdom_parser.print_help()
return 2
_emit(result, as_json=bool(getattr(args, "json", False)))
return 0
except WisdomError as exc:
_emit(
{"ok": False, "error": str(exc), "category": exc.exit_code},
as_json=bool(getattr(args, "json", False)),
)
return exc.exit_code
except PackagePolicyError as exc:
_emit(
{"ok": False, "error": str(exc), "category": 6},
as_json=bool(getattr(args, "json", False)),
)
return 6
except KeyboardInterrupt:
return 7
def build_wisdom_parser(subparsers) -> None:
from hermes_wisdom.entitlement import is_entitled
if not is_entitled():
return
parser = subparsers.add_parser(
"wisdom",
help="Collective Wisdom — review, share, and install team skills",
description=(
"Local qualification stays on this device. Publication uploads only an owner-private "
"instruction package and requires a complete, hash-bound owner review."
),
)
parser.set_defaults(func=cmd_wisdom, _wisdom_parser=parser)
commands = parser.add_subparsers(dest="wisdom_command")
def add(name: str, help_text: str) -> argparse.ArgumentParser:
command = commands.add_parser(name, help=help_text)
command.add_argument(
"--json", action="store_true", help="Emit stable machine-readable JSON"
)
return command
setup = add("setup", "Validate entitlement and initialize this profile")
setup.add_argument(
"--accept-disclosure",
action="store_true",
help="Accept the local telemetry and owner-private draft disclosure",
)
add("status", "Show local and Gateway Wisdom status")
installed_setup = add("installed-setup", "Inspect installed setup guidance without executing it")
installed_setup.add_argument("skill_id")
installed_setup.add_argument("--version", type=int, help="Require this exact installed version")
sync = add("sync", "Check or retry saved notification receipts and operation reports")
sync.add_argument("action", choices=("status", "retry"), nargs="?", default="status")
add("inbox", "Read agent advice and pending native consent")
consent = add("consent", "Inspect or confirm an exact local interaction")
consent.add_argument("interaction_id")
consent.add_argument("action", default="inspect", nargs="?", metavar="inspect[.PAGE]|defer|confirm")
scan = add("scan", "Run local policy and advisory scans")
scan.add_argument("skill", nargs="?")
suggest = add("suggest", "Browse candidates or submit an owner-private draft")
suggest.add_argument("skill", nargs="?")
suggest.add_argument("--description", help="Owner-edited outcome description")
suggest.add_argument(
"--system-specification-json",
dest="system_specification",
help="Owner-reviewed declarative System Specification JSON",
)
suggest.add_argument(
"--send-for-owner-only-server-review",
dest="private_secret_override",
action="store_true",
help="Explicitly override a high-confidence local secret pause for owner-private review",
)
add("candidates", "List all manually selectable local candidates")
review = add("review", "Review exact server draft bytes and hashes")
review.add_argument("draft_id")
review.add_argument(
"--portal", action="store_true", help="Open the authenticated Portal review"
)
review.add_argument(
"--acknowledge",
action="store_true",
help="Record a receipt after complete review",
)
approve = add("approve", "Approve a freshly reviewed draft and publish")
approve.add_argument("draft_id")
decline = add("decline", "Decline an owner-private draft")
decline.add_argument("draft_id")
add("list", "List published Collective Wisdom skills")
show = add("show", "Show one published skill")
show.add_argument("skill_id")
versions = add("versions", "List published versions")
versions.add_argument("skill_id")
install = add("install", "Plan or apply an authenticated managed install")
install.add_argument("reference", nargs="?", default="")
install.add_argument(
"--plan", action="store_true", help="Create a plan receipt without applying"
)
install.add_argument(
"--apply-receipt", help="Apply a previously reviewed plan receipt"
)
install.add_argument(
"--accept-partial",
action="store_true",
help="Accept partial/setup-required compatibility",
)
install.add_argument(
"--update-mode", choices=["MANUAL", "AUTO_WITH_NOTICE", "REQUIRED"]
)
add("check", "Reconcile the feed and check managed installations")
update = add("update", "Plan or apply verified managed updates")
update.add_argument("skill_id", nargs="?")
update.add_argument(
"--all", action="store_true", help="Process every managed install"
)
update.add_argument(
"--plan", action="store_true", help="Create a plan without applying"
)
update.add_argument(
"--apply-receipt", help="Apply a previously reviewed update plan"
)
update.add_argument(
"--accept-sensitive",
action="store_true",
help="Explicitly accept newly declared sensitive requirements",
)
update.add_argument(
"--accept-partial",
action="store_true",
help="Accept partial/setup-required compatibility",
)
update.add_argument(
"--preserve-modified",
action="store_true",
help="Preserve locally modified managed bytes as an unmanaged fork",
)
uninstall = add("uninstall", "Move a managed skill to recoverable trash")
uninstall.add_argument("skill_id")
uninstall.add_argument("--yes", action="store_true", help="Confirm uninstall")
notifications = add("notifications", "Show durable local Wisdom notices")
notifications.add_argument("--mark-seen", action="store_true")
browse = add("browse", "Search the organization catalog by keyword")
browse.add_argument("query", nargs="?")
review_week = add(
"review-week", "Queue this week's review for the active private agent session"
)
review_week.add_argument(
"--force", action="store_true", help="Compatibility flag; does not bypass the weekly cap or consent"
)
review_week.add_argument(
"--dry-run", action="store_true", help="Preview local evidence without queueing or calling a model"
)
act = add("act", "Open current review for an old recommendation control (does not apply)")
act.add_argument("target")
act.add_argument("--mute", choices=["1d", "1w", "30d", "forever"], help="Mute duration")
share = add("share", "Agent-guided share flow (package, review, approve)")
share.add_argument(
"share_step",
choices=["start", "package", "request-changes", "approve", "cancel", "status"],
)
share.add_argument("skill", nargs="?", help="Skill name (start step)")
share.add_argument("--flow-id", dest="flow_id", help="Resume an existing flow")
share.add_argument("--note", help="Requested changes (request-changes step)")
dismiss = add("dismiss", "Record 'Not now' for a skill at its current content hash")
dismiss.add_argument("skill")
dismiss.add_argument("content_hash")
dismiss.add_argument("--days", type=int, help="Suppression window (default from policy)")
mute = add("mute", "Manage your organization-wide proactive Wisdom notifications")
mute.add_argument("duration", nargs="?", default="status", choices=["status", "1d", "1w", "30d", "forever", "off"])
+143 -7
View File
@@ -5,9 +5,7 @@ from __future__ import annotations
import math
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, SecretStr, StrictBool, field_validator
from pydantic import BaseModel, Field, SecretStr, StrictBool, field_validator
class ConfigUpdate(BaseModel):
config: dict
profile: Optional[str] = None
@@ -94,6 +92,7 @@ class ModelAssignment(BaseModel):
scope="main" → model.provider + model.default; scope="auxiliary" → auxiliary.<task>.*
(task="" = every auxiliary slot, task="__reset__" = reset every slot to provider="auto").
"""
scope: str
provider: str
model: str
@@ -199,7 +198,6 @@ class GitBranchSwitchBody(BaseModel):
class CuratorPause(BaseModel):
paused: bool
class LearningNodeRef(BaseModel):
id: str
profile: Optional[str] = None
@@ -209,11 +207,148 @@ class LearningNodeEdit(BaseModel):
content: str
profile: Optional[str] = None
class WisdomSuggestRequest(BaseModel):
skill: Optional[str] = None
local_skill_id: Optional[str] = None
description: Optional[str] = None
system_specification: Optional[Dict[str, Any]] = None
send_for_owner_only_server_review: bool = False
profile: Optional[str] = None
class WisdomSetupRequest(BaseModel):
accept_disclosure: bool = False
profile: Optional[str] = None
class WisdomScanRequest(BaseModel):
skill: Optional[str] = None
profile: Optional[str] = None
class WisdomReviewRequest(BaseModel):
draft_id: str
acknowledge: bool = False
profile: Optional[str] = None
expected_hashes: Optional[Dict[str, str]] = None
class WisdomPublicationRequest(BaseModel):
draft_id: str
expected_hashes: Dict[str, str]
publication_mode: Literal["open", "managed", "moderated"]
profile: Optional[str] = None
interaction_id: Optional[str] = None
session_id: Optional[str] = None
class WisdomEditedFile(BaseModel):
path: str = Field(min_length=1, max_length=1024)
content_utf8: str = Field(max_length=256 * 1024)
class WisdomPreparedSaveRequest(BaseModel):
draft_id: str
author_description: str = Field(min_length=1, max_length=4096)
files: List[WisdomEditedFile] = Field(min_length=2, max_length=32)
profile: Optional[str] = None
class WisdomCandidateDismissRequest(BaseModel):
local_skill_id: str
content_hash: str
profile: Optional[str] = None
class WisdomCandidateEventRequest(BaseModel):
event_id: str
profile: Optional[str] = None
class WisdomReviseRequest(BaseModel):
draft_id: str
author_description: str = Field(min_length=1, max_length=4096)
files: List[WisdomEditedFile] = Field(min_length=2, max_length=32)
expected_content_hash: str
expected_author_description_hash: str
expected_package_manifest_hash: str
send_for_owner_only_server_review: bool = False
profile: Optional[str] = None
class WisdomDecisionRequest(BaseModel):
draft_id: str
profile: Optional[str] = None
class WisdomInstallPlanRequest(BaseModel):
reference: str
update_mode: Optional[Literal["MANUAL", "AUTO_WITH_NOTICE", "REQUIRED"]] = None
profile: Optional[str] = None
class WisdomInstallApplyRequest(BaseModel):
receipt: str
accept_partial: bool = False
profile: Optional[str] = None
class WisdomCheckRequest(BaseModel):
apply_automatic: bool = False
profile: Optional[str] = None
class WisdomUpdatePlanRequest(BaseModel):
skill_id: str
profile: Optional[str] = None
class WisdomUpdateApplyRequest(BaseModel):
receipt: str
accept_sensitive: bool = False
accept_partial: bool = False
preserve_modified: bool = False
profile: Optional[str] = None
class WisdomUninstallRequest(BaseModel):
skill_id: str
profile: Optional[str] = None
class WisdomNotificationRequest(BaseModel):
mark_seen: bool = False
profile: Optional[str] = None
class WisdomConsentRequest(BaseModel):
model_config = {"extra": "forbid"}
interaction_id: str = Field(min_length=1, max_length=64)
session_id: str = Field(min_length=1, max_length=256)
action: str = Field(pattern=r"^(inspect(?:\.[0-9]{1,4})?|defer|confirm|recheck|setup\.(status|recover|clear))$")
profile: Optional[str] = None
class WisdomSyncRetryRequest(BaseModel):
model_config = {"extra": "forbid"}
profile: Optional[str] = None
class WisdomMutePrepareRequest(BaseModel):
model_config = {"extra": "forbid"}
profile: Optional[str] = None
class WisdomMuteChooseRequest(WisdomMutePrepareRequest):
control_id: str = Field(pattern=r"^[a-f0-9]{32}$")
duration: Optional[Literal["1_day", "1_week", "30_days", "forever"]]
class DebugShareRequest(BaseModel):
# Redaction scrubs credential-shaped tokens before logs leave the machine; opt-out only.
redact: bool = True
lines: int = 200 # recent log lines in the summary tail (full logs are separate)
class TTSSpeakRequest(BaseModel):
text: str
@@ -301,6 +436,7 @@ class AutomationBlueprintInstantiate(BaseModel):
blueprint: str # blueprint key, e.g. "morning-brief"
values: Dict[str, Any] = {} # filled slot values from the form
class MCPServerCreate(BaseModel):
name: str
url: Optional[str] = None
@@ -350,6 +486,7 @@ class WebhookCreate(BaseModel):
deliver_chat_id: Optional[str] = None
secret: Optional[str] = None # omit to auto-generate
class WebhookEnabledToggle(BaseModel):
enabled: bool
@@ -364,9 +501,11 @@ class MemoryProviderSelect(BaseModel):
class MemoryReset(BaseModel):
target: str = "all" # "all" | "memory" | "user"
class BackupRequest(BaseModel):
output: Optional[str] = None # defaults to a timestamped zip in the home dir
class ImportRequest(BaseModel):
archive: str
# --force: the spawned `hermes import` has stdin=DEVNULL, so its "Continue? [y/N]" prompt would
@@ -395,7 +534,6 @@ class SkillUninstallRequest(BaseModel):
class SkillsUpdateRequest(BaseModel):
profile: Optional[str] = None
class ProfileCreate(BaseModel):
name: str
clone_from: Optional[str] = None
@@ -491,7 +629,6 @@ class ThemeSetBody(BaseModel):
class FontSetBody(BaseModel):
font: str
class _AgentPluginInstallBody(BaseModel):
identifier: str
force: bool = False
@@ -507,4 +644,3 @@ class _PluginProvidersPutBody(BaseModel):
class _PluginVisibilityBody(BaseModel):
hidden: bool
+21 -2
View File
@@ -77,6 +77,8 @@ def _flag(obj, attr: str) -> bool:
def _skill_meta_to_payload(m) -> dict:
return {
"name": m.name, "description": m.description, "source": m.source,
"editorial_name": getattr(m, "editorial_name", None) or m.name,
"editorial_description": getattr(m, "editorial_description", None) or m.description,
"identifier": m.identifier, "trust_level": m.trust_level, "repo": m.repo,
"tags": list(m.tags or [])}
@@ -251,8 +253,23 @@ async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None)
skill_md = files.get("SKILL.md", "") or ""
m = meta or bundle
from agent.skill_utils import (
extract_skill_editorial_metadata,
parse_frontmatter,
)
name = getattr(m, "name", ident)
description = getattr(m, "description", "") or ""
frontmatter, _body = parse_frontmatter(skill_md)
editorial = extract_skill_editorial_metadata(
frontmatter,
fallback_name=name,
fallback_description=description,
)
return {
"name": getattr(m, "name", ident), "description": getattr(m, "description", "") or "",
"name": name,
"description": description,
**editorial,
"source": getattr(m, "source", "") or "",
"identifier": getattr(m, "identifier", ident) or ident,
"trust_level": getattr(m, "trust_level", "community") or "community",
@@ -351,7 +368,9 @@ async def get_skills(profile: Optional[str] = None):
with _profile_scope(profile):
config = load_config()
disabled = get_disabled_skills(config)
skills = _find_all_skills(skip_disabled=True)
skills = _find_all_skills(
skip_disabled=True, include_editorial=True
)
usage = load_usage()
# Set-based provenance (same classification as skill_usage.provenance,
# without a per-skill manifest read): hub > bundled > agent, where
+542
View File
@@ -0,0 +1,542 @@
"""Profile-scoped Collective Wisdom API for Dashboard and Desktop.
Gateway credentials and endpoints remain inside this process.
"""
import asyncio
import hashlib
import re
from typing import Any, List, Optional
from fastapi import APIRouter, HTTPException
from hermes_cli.web_models import (
WisdomSuggestRequest,
WisdomReviewRequest,
WisdomPublicationRequest,
WisdomPreparedSaveRequest,
WisdomCandidateDismissRequest,
WisdomCandidateEventRequest,
WisdomReviseRequest,
WisdomDecisionRequest,
WisdomSetupRequest,
WisdomScanRequest,
WisdomInstallPlanRequest,
WisdomInstallApplyRequest,
WisdomCheckRequest,
WisdomUpdatePlanRequest,
WisdomUpdateApplyRequest,
WisdomUninstallRequest,
WisdomNotificationRequest,
WisdomConsentRequest,
WisdomMutePrepareRequest,
WisdomSyncRetryRequest,
WisdomMuteChooseRequest,
)
from hermes_cli.web_routers._common import (
_profile_scope,
_spawn_hermes_action,
log as _log,
)
from hermes_cli.web_server_profiles import _profile_cli_args
from hermes_cli.web_server_gateway import _ACTION_LOG_FILES, _ACTION_PROCS
router = APIRouter()
def _wisdom_http_error(exc: Exception) -> HTTPException:
from hermes_wisdom.client import WisdomError
from hermes_wisdom.package import PackagePolicyError
if isinstance(exc, WisdomError):
status = {
3: 403,
4: 404,
5: 409,
6: 422,
7: 409,
8: 503,
}.get(exc.exit_code, 500)
return HTTPException(status_code=status, detail=str(exc))
if isinstance(exc, PackagePolicyError):
return HTTPException(status_code=422, detail=str(exc))
_log.exception("Collective Wisdom request failed")
return HTTPException(status_code=500, detail="Collective Wisdom request failed")
async def _run_wisdom(profile: Optional[str], fn, *, require_setup: bool = True):
def run():
with _profile_scope(profile):
from hermes_wisdom.service import WisdomService
from hermes_wisdom.entitlement import require_entitlement
require_entitlement()
service = WisdomService()
if require_setup:
service.require_setup()
return fn(service)
try:
return await asyncio.to_thread(run)
except Exception as exc:
raise _wisdom_http_error(exc) from exc
def _schedule_wisdom_professionalism_reviews(profile: Optional[str]) -> None:
"""Wake the local review worker after Dashboard/Desktop creates new work."""
async def run() -> None:
try:
await _run_wisdom(
profile,
lambda service: service.process_professionalism_reviews(max_jobs=4),
)
except Exception:
_log.debug("Collective Wisdom professionalism worker failed", exc_info=True)
asyncio.create_task(run())
@router.get("/api/wisdom/entitlement")
async def get_wisdom_entitlement(profile: Optional[str] = None):
"""Expose refresh-free local JWT entitlement metadata for UI gating."""
def run():
with _profile_scope(profile):
from hermes_wisdom.entitlement import current_entitlement
# Derive the boolean and metadata from one auth/token snapshot so a
# concurrent token replacement cannot produce a mismatched response.
metadata = current_entitlement()
scopes = tuple(metadata.get("scopes", ()))
return {
"entitled": "wisdom:read" in scopes,
"org_id": metadata.get("org_id"),
"scopes": list(scopes),
"expires_at": metadata.get("expires_at"),
}
try:
return await asyncio.to_thread(run)
except Exception:
# This presentation gate must fail closed and must never trigger auth refresh.
_log.debug("Collective Wisdom entitlement probe failed", exc_info=True)
return {"entitled": False, "org_id": None, "scopes": [], "expires_at": None}
@router.get("/api/wisdom/status")
async def get_wisdom_status(profile: Optional[str] = None):
return await _run_wisdom(
profile, lambda service: service.status(), require_setup=False
)
@router.get("/api/wisdom/sync")
async def get_wisdom_sync(profile: Optional[str] = None):
return await _run_wisdom(profile, lambda service: service.sync_status())
@router.post("/api/wisdom/sync/retry")
async def post_wisdom_sync_retry(body: WisdomSyncRetryRequest):
return await _run_wisdom(body.profile, lambda service: service.retry_sync())
def _wisdom_action_name(verb: str, profile: Optional[str]) -> str:
scope = (profile or "current").strip().lower() or "current"
slug = re.sub(r"[^a-z0-9]+", "-", scope).strip("-")[:32] or "current"
digest = hashlib.sha1(scope.encode()).hexdigest()[:8]
name = f"wisdom-{verb}-{slug}-{digest}"
_ACTION_LOG_FILES.setdefault(name, f"action-{name}.log")
return name
def _spawn_wisdom_action(
verb: str, profile: Optional[str], command: List[str]
) -> dict[str, Any]:
name = _wisdom_action_name(verb, profile)
existing = _ACTION_PROCS.get(name)
if existing is not None and existing.poll() is None:
return {"ok": True, "pid": existing.pid, "name": name, "reused": True}
args = [*_profile_cli_args(profile), "wisdom", *command, "--json"]
proc = _spawn_hermes_action(args, name)
return {"ok": True, "pid": proc.pid, "name": name, "reused": False}
@router.post("/api/wisdom/setup")
async def post_wisdom_setup(body: WisdomSetupRequest):
if not body.accept_disclosure:
raise HTTPException(
status_code=422,
detail="Collective Wisdom setup requires explicit disclosure acceptance",
)
try:
return _spawn_wisdom_action(
"setup", body.profile, ["setup", "--accept-disclosure"]
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to start Collective Wisdom setup")
raise HTTPException(status_code=500, detail=f"Failed to start setup: {exc}")
@router.post("/api/wisdom/scan")
async def post_wisdom_scan(body: WisdomScanRequest):
command = ["scan", *([body.skill] if body.skill else [])]
try:
return _spawn_wisdom_action("scan", body.profile, command)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to start Collective Wisdom scan")
raise HTTPException(status_code=500, detail=f"Failed to start scan: {exc}")
@router.get("/api/wisdom/candidates")
async def get_wisdom_candidates(profile: Optional[str] = None):
return await _run_wisdom(
profile, lambda service: {"candidates": service.scan_candidates()}
)
@router.get("/api/wisdom/events")
async def get_wisdom_events(
profile: Optional[str] = None, session_id: Optional[str] = None
):
def read(service):
from hermes_wisdom.mediation import delivery_mode
if delivery_mode() == "agent":
return {"events": [], "delivery_mode": "agent"}
return {
"events": service.pending_candidate_events(
session_id=session_id or "", surface="desktop"
)
}
return await _run_wisdom(profile, read)
@router.get("/api/wisdom/drafts")
async def get_wisdom_drafts(profile: Optional[str] = None):
return await _run_wisdom(
profile,
lambda service: {
"drafts": [
draft.model_dump(mode="json") for draft in service.client.list_drafts()
]
},
)
@router.get("/api/wisdom/discovery")
async def get_wisdom_discovery(profile: Optional[str] = None):
return await _run_wisdom(profile, lambda service: service.list_skills())
@router.get("/api/wisdom/skills/{skill_id}")
async def get_wisdom_skill(skill_id: str, profile: Optional[str] = None):
# Command views intentionally show human-readable slugs. Accept the same
# opaque-ID-or-exact-slug references as `/wisdom show` so Desktop preview
# actions can consume command output without leaking registry IDs into it.
return await _run_wisdom(profile, lambda service: service.resolve_skill(skill_id))
@router.get("/api/wisdom/skills/{skill_id}/versions/{version}")
async def get_wisdom_version(
skill_id: str, version: int, profile: Optional[str] = None
):
return await _run_wisdom(
profile, lambda service: service.version_detail(skill_id, version)
)
@router.get("/api/wisdom/skills/{skill_id}/versions/{version}/content")
async def get_wisdom_version_content(
skill_id: str, version: int, profile: Optional[str] = None
):
return await _run_wisdom(
profile, lambda service: service.version_content(skill_id, version)
)
@router.post("/api/wisdom/suggest")
async def post_wisdom_suggest(body: WisdomSuggestRequest):
result = await _run_wisdom(
body.profile,
lambda service: service.suggest(
body.skill,
description=body.description,
system_specification=body.system_specification,
allow_private_secret_review=body.send_for_owner_only_server_review,
local_skill_id=body.local_skill_id,
),
)
if result.get("network_submission") is False and result.get("local_draft_id"):
_schedule_wisdom_professionalism_reviews(body.profile)
return result
@router.post("/api/wisdom/review")
async def post_wisdom_review(body: WisdomReviewRequest):
return await _run_wisdom(
body.profile,
lambda service: service.review(
body.draft_id, acknowledge=body.acknowledge, portal=False,
expected_hashes=body.expected_hashes,
),
)
@router.post("/api/wisdom/publication/review")
async def post_wisdom_publication_review(body: WisdomDecisionRequest):
return await _run_wisdom(body.profile, lambda service: service.publication_review(body.draft_id))
@router.post("/api/wisdom/publication/submit")
async def post_wisdom_publication_submit(body: WisdomPublicationRequest):
if set(body.expected_hashes) != {"content", "author_description", "package_manifest"}:
raise HTTPException(status_code=422, detail="Confirmation requires all three displayed package hashes")
if body.interaction_id or body.session_id:
if not body.interaction_id or not body.session_id:
raise HTTPException(status_code=422, detail="Both interaction and session are required")
def submit(service):
from hermes_wisdom.consent import ConsentActor, WisdomConsent
actor = ConsentActor(body.session_id, "local", "local-user", f"local:{body.session_id}")
return WisdomConsent(service).submit_local_publication(
service.store.active_org_id(), body.interaction_id, actor,
draft_id=body.draft_id, expected_hashes=body.expected_hashes, publication_mode=body.publication_mode,
)
return await _run_wisdom(body.profile, submit)
return await _run_wisdom(
body.profile,
lambda service: service.submit_reviewed_package(
body.draft_id, expected_hashes=body.expected_hashes,
publication_mode=body.publication_mode,
),
)
@router.post("/api/wisdom/prepared/save")
async def post_wisdom_prepared_save(body: WisdomPreparedSaveRequest):
result = await _run_wisdom(
body.profile,
lambda service: service.save_prepared(
body.draft_id,
author_description=body.author_description,
files=[item.model_dump() for item in body.files],
),
)
_schedule_wisdom_professionalism_reviews(body.profile)
return result
@router.post("/api/wisdom/candidates/dismiss")
async def post_wisdom_candidate_dismiss(body: WisdomCandidateDismissRequest):
return await _run_wisdom(
body.profile,
lambda service: service.dismiss_local_candidate(
body.local_skill_id, body.content_hash
),
)
@router.post("/api/wisdom/candidates/defer")
async def post_wisdom_candidate_defer(body: WisdomCandidateEventRequest):
return await _run_wisdom(
body.profile,
lambda service: service.defer_candidate_prompt(
body.event_id, surface="desktop"
),
)
@router.post("/api/wisdom/candidates/prepare")
async def post_wisdom_candidate_prepare(body: WisdomCandidateEventRequest):
result = await _run_wisdom(
body.profile, lambda service: service.prepare_candidate(body.event_id)
)
if result.get("stage") == "prepared":
_schedule_wisdom_professionalism_reviews(body.profile)
return result
@router.post("/api/wisdom/candidates/approve")
async def post_wisdom_candidate_approve(body: WisdomCandidateEventRequest):
return await _run_wisdom(
body.profile, lambda service: service.approve_candidate(body.event_id)
)
@router.post("/api/wisdom/revise")
async def post_wisdom_revise(body: WisdomReviseRequest):
return await _run_wisdom(
body.profile,
lambda service: service.revise(
body.draft_id,
author_description=body.author_description,
files=[item.model_dump() for item in body.files],
expected_content_hash=body.expected_content_hash,
expected_description_hash=body.expected_author_description_hash,
expected_manifest_hash=body.expected_package_manifest_hash,
allow_private_secret_review=body.send_for_owner_only_server_review,
),
)
@router.post("/api/wisdom/approve")
async def post_wisdom_approve(body: WisdomDecisionRequest):
return await _run_wisdom(
body.profile, lambda service: service.approve(body.draft_id)
)
@router.post("/api/wisdom/decline")
async def post_wisdom_decline(body: WisdomDecisionRequest):
return await _run_wisdom(
body.profile, lambda service: service.decline(body.draft_id)
)
@router.post("/api/wisdom/install/plan")
async def post_wisdom_install_plan(body: WisdomInstallPlanRequest):
return await _run_wisdom(
body.profile,
lambda service: service.install_plan(
body.reference, update_mode=body.update_mode
),
)
@router.post("/api/wisdom/install/apply")
async def post_wisdom_install_apply(body: WisdomInstallApplyRequest):
return await _run_wisdom(
body.profile,
lambda service: service.install_apply(
body.receipt, accept_partial=body.accept_partial
),
)
@router.get("/api/wisdom/installations")
async def get_wisdom_installations(profile: Optional[str] = None):
def read(service):
from hermes_wisdom.mediation import delivery_mode
return {
"delivery_mode": delivery_mode(),
"installations": service.store.installations(),
"notifications": service.notifications(mark_seen=False)["events"],
}
return await _run_wisdom(profile, read)
@router.post("/api/wisdom/check")
async def post_wisdom_check(body: WisdomCheckRequest):
return await _run_wisdom(
body.profile,
lambda service: service.check(apply_automatic=body.apply_automatic),
)
@router.post("/api/wisdom/update/plan")
async def post_wisdom_update_plan(body: WisdomUpdatePlanRequest):
return await _run_wisdom(
body.profile, lambda service: service.update_plan(body.skill_id)
)
@router.post("/api/wisdom/update/apply")
async def post_wisdom_update_apply(body: WisdomUpdateApplyRequest):
return await _run_wisdom(
body.profile,
lambda service: service.update_apply(
body.receipt,
accept_sensitive=body.accept_sensitive,
accept_partial=body.accept_partial,
preserve_modified=body.preserve_modified,
),
)
@router.post("/api/wisdom/uninstall")
async def post_wisdom_uninstall(body: WisdomUninstallRequest):
return await _run_wisdom(
body.profile, lambda service: service.uninstall(body.skill_id)
)
@router.post("/api/wisdom/notifications")
async def post_wisdom_notifications(body: WisdomNotificationRequest):
return await _run_wisdom(
body.profile,
lambda service: service.notifications(mark_seen=body.mark_seen),
)
@router.get("/api/wisdom/mediation")
async def get_wisdom_mediation(profile: Optional[str] = None):
from hermes_wisdom.mediation import WisdomMediation
from hermes_wisdom.mediation_view import desktop_interaction
def activity(service):
value = WisdomMediation(service).activity()
return {**value, "interactions": [desktop_interaction(item) for item in value["interactions"]]}
return await _run_wisdom(profile, activity)
@router.get("/api/wisdom/mute")
async def get_wisdom_mute(profile: Optional[str] = None):
from hermes_wisdom.preferences import WisdomPreferences
return await _run_wisdom(profile, lambda service: WisdomPreferences(service).native_mute_command())
@router.post("/api/wisdom/mute/prepare")
async def post_wisdom_mute_prepare(body: WisdomMutePrepareRequest):
def prepare(service):
from hermes_wisdom.preferences import WisdomPreferences
preferences = WisdomPreferences(service)
org = service.store.active_org_id()
control = preferences.prepare_mute_control(org)
return {**control, "organization_id": org, "sync": preferences.mute_status(org)}
return await _run_wisdom(body.profile, prepare)
@router.post("/api/wisdom/mute/choose")
async def post_wisdom_mute_choose(body: WisdomMuteChooseRequest):
def choose(service):
from hermes_wisdom.preferences import WisdomPreferences
preferences = WisdomPreferences(service)
preferences.choose_mute_control(service.store.active_org_id(), body.control_id, body.duration)
return preferences.native_mute_command()
return await _run_wisdom(body.profile, choose)
@router.post("/api/wisdom/consent")
async def post_wisdom_consent(body: WisdomConsentRequest):
def resolve(service):
from hermes_wisdom.consent import ConsentActor, WisdomConsent
from hermes_wisdom.mediation_view import desktop_interaction
actor = ConsentActor(body.session_id, "local", "local-user", f"local:{body.session_id}")
return desktop_interaction(WisdomConsent(service).resolve(
service.store.active_org_id(), body.interaction_id, actor, body.action
))
return await _run_wisdom(body.profile, resolve)
@router.post("/api/wisdom/consent/publication-review")
async def post_wisdom_consent_publication_review(body: WisdomConsentRequest):
def prepare(service):
from hermes_wisdom.consent import ConsentActor, WisdomConsent
actor = ConsentActor(body.session_id, "local", "local-user", f"local:{body.session_id}")
return WisdomConsent(service).prepare_local_publication(service.store.active_org_id(), body.interaction_id, actor)
return await _run_wisdom(body.profile, prepare)
+5
View File
@@ -69,6 +69,7 @@ from hermes_cli.web_server_lifecycle import ( # noqa: E402
_report_port_in_use,
_start_parent_death_watchdog,
_warm_gateway_module,
_wisdom_checker_loop,
_write_dashboard_ready_file,
_write_machine_sentinel_line,
)
@@ -219,6 +220,7 @@ async def _lifespan(app: "FastAPI"):
selftest_task = asyncio.create_task(_dashboard_selftest_loop())
# Live auto-archive timer, independent of list requests.
auto_archive_task = asyncio.create_task(_auto_archive_ticker_loop())
wisdom_checker_task = asyncio.create_task(_wisdom_checker_loop())
# Managed local runtime (local_runtime.enabled): bring llama-server back so a
# restart doesn't strand a llamacpp main model. Off-thread and best-effort;
@@ -254,6 +256,7 @@ async def _lifespan(app: "FastAPI"):
pty_reaper_task.cancel()
selftest_task.cancel()
auto_archive_task.cancel()
wisdom_checker_task.cancel()
await PTY_REGISTRY.close_all()
# Stop the managed llama-server with its parent (an orphan pins VRAM).
try:
@@ -938,6 +941,7 @@ from hermes_cli.web_routers import ( # noqa: E402
mcp as _mcp_routes,
ops as _ops_routes,
skills as _skills_routes,
wisdom as _wisdom_routes,
tools as _tools_routes,
analytics as _analytics_routes,
chat_ws as _chat_ws_routes,
@@ -968,6 +972,7 @@ app.include_router(_ops_routes.router)
app.include_router(_skills_routes.hub_router)
app.include_router(_profiles_routes.router)
app.include_router(_skills_routes.router)
app.include_router(_wisdom_routes.router)
app.include_router(_tools_routes.router)
app.include_router(_analytics_routes.router)
app.include_router(_chat_ws_routes.router)
+25
View File
@@ -1,6 +1,7 @@
"""Serve-process lifecycle: parent death watchdog, port-conflict preflight, READY announcement, browser open, trusted proxies.
"""
import asyncio
import logging
import ipaddress
import json
@@ -189,6 +190,30 @@ def _eager_reconcile_own_session_db() -> None:
)
async def _wisdom_checker_loop(interval: int = 300) -> None:
"""Run pending reviews and reconcile the typed feed off the request loop."""
while True:
try:
from hermes_cli.config import load_config
wisdom = (load_config() or {}).get("wisdom") or {}
if isinstance(wisdom, dict) and wisdom.get("enabled"):
def reconcile_wisdom():
from hermes_wisdom.service import WisdomService
service = WisdomService()
service.require_setup()
service.process_professionalism_reviews(max_jobs=4)
return service.check(apply_automatic=False)
await asyncio.to_thread(reconcile_wisdom)
except asyncio.CancelledError:
raise
except Exception:
_log.debug("Collective Wisdom background reconciliation failed", exc_info=True)
await asyncio.sleep(interval)
def _read_bound_port(server: "uvicorn.Server", fallback: int) -> int:
"""Read the OS-assigned port from the live uvicorn socket (ephemeral port-0 discovery)."""
if server.servers and server.servers[0].sockets:
+5
View File
@@ -0,0 +1,5 @@
"""Hermes Collective Wisdom local plane and Gateway client."""
from .contract import CONTRACT_PIN
__all__ = ["CONTRACT_PIN"]
+127
View File
@@ -0,0 +1,127 @@
"""Retire and resume profile-local Wisdom work at account session boundaries."""
import time
from typing import TYPE_CHECKING
from hermes_constants import get_hermes_home
from .store import WisdomStore, utc_now
if TYPE_CHECKING:
from .client import WisdomClient
def sign_out(store: WisdomStore | None = None) -> bool:
if store is None:
root = get_hermes_home() / "wisdom"
if not (root / "wisdom.db").is_file():
return False
store = WisdomStore(root)
now = time.time()
with store.transaction() as db:
# Keep history and provider receipts, but do not replay cached arrivals
# or accept a response fetched under the account session being ended.
db.execute("UPDATE feed_event SET cadence='off' WHERE cadence!='off'")
db.execute(
"""INSERT INTO feed_state(singleton,cursor,updated_at,generation,resume_required)
VALUES(1,NULL,?,1,1) ON CONFLICT(singleton) DO UPDATE SET
generation=feed_state.generation+1,resume_required=1,updated_at=excluded.updated_at""",
(utc_now(),),
)
changed = db.execute(
"""UPDATE installation_identity SET verified_org_id=NULL,verified_at=NULL
WHERE verified_org_id IS NOT NULL"""
).rowcount
db.execute(
"""UPDATE wisdom_assessment SET state='retired',lease_token=NULL,
lease_until=NULL,updated_at=?
WHERE state IN ('pending','assessing','ready','fallback')""",
(now,),
)
# A send already dispatched may still succeed. Keep its immutable outbox
# reservation so a late receipt can settle it, never schedule a resend.
db.execute(
"""UPDATE wisdom_assessment SET state='delivery_uncertain',
lease_token=NULL,lease_until=NULL,updated_at=? WHERE state='delivering'""",
(now,),
)
db.execute(
"UPDATE wisdom_consent SET state='stale',updated_at=? WHERE state='pending'",
(now,),
)
db.execute("UPDATE wisdom_agent_session SET available=0,alive_until=0")
db.execute("UPDATE wisdom_mute_control SET expires_at=0")
return bool(changed)
def resume_feed(
store: WisdomStore,
client: "WisdomClient",
*,
org_id: str,
installation_id: str,
generation: int,
) -> int:
"""Checkpoint signed-out arrivals silently before setup restores authority."""
from .client import WisdomValidationError
with store.transaction() as db:
store.check_feed_generation(db, generation)
row = db.execute("SELECT * FROM feed_state WHERE singleton=1").fetchone()
same_scope = bool(
row
and row["organization_id"] == org_id
and row["installation_id"] == installation_id
)
cursor = row["cursor"] if same_scope else None
required = bool(row and row["resume_required"])
if not same_scope:
db.execute("UPDATE feed_event SET cadence='off' WHERE cadence!='off'")
generation += 1
db.execute(
"""INSERT INTO feed_state(singleton,cursor,updated_at,generation,
organization_id,installation_id,resume_required) VALUES(1,?,?,?,?,?,?)
ON CONFLICT(singleton) DO UPDATE SET cursor=excluded.cursor,
updated_at=excluded.updated_at,generation=excluded.generation,
organization_id=excluded.organization_id,installation_id=excluded.installation_id,
resume_required=excluded.resume_required""",
(cursor, utc_now(), generation, org_id, installation_id, int(required)),
)
db.execute(
"UPDATE installation_identity SET verified_org_id=NULL,verified_at=NULL"
)
if not required:
return generation
for _ in range(100):
page = client.feed(cursor, installation_id=installation_id)
if page.has_more and page.next_cursor == cursor:
raise WisdomValidationError(
"Wisdom feed catch-up made no progress; retry setup"
)
with store.transaction() as db:
store.check_feed_generation(db, generation)
db.execute(
"UPDATE feed_state SET cursor=?,updated_at=? WHERE singleton=1",
(page.next_cursor, utc_now()),
)
cursor = page.next_cursor
if not page.has_more:
return generation
raise WisdomValidationError(
"Wisdom feed catch-up paused at its page limit; rerun setup to resume"
)
def reject_revoked_account(store: WisdomStore) -> None:
from hermes_cli.auth_nous import NOUS_SESSION_TERMINAL, get_nous_session_validity
from .client import WisdomAuthError
# This reads the persisted quarantine marker, never refreshes credentials.
# Network errors and ordinary token expiry are not proof of revocation.
if get_nous_session_validity() == NOUS_SESSION_TERMINAL:
sign_out(store)
raise WisdomAuthError(
"Your Nous session ended; sign in and re-verify your team with `hermes wisdom setup`.",
code="account_session_ended",
)
+13
View File
@@ -0,0 +1,13 @@
"""Agent-led Collective Wisdom sharing.
This package layers an agent-authored review on top of the deterministic
qualification signals in :mod:`hermes_wisdom.qualification`. Nothing in it
publishes, installs, or mutes on its own: every outbound action passes
through the existing owner-consent gates in :mod:`hermes_wisdom.service`.
"""
from __future__ import annotations
from .policy import AgentLedPolicy, load_policy
__all__ = ["AgentLedPolicy", "load_policy"]
+92
View File
@@ -0,0 +1,92 @@
"""Read-only compatibility for pre-mediation recommendation controls.
The old JSON ledger did not bind a control to a native actor or destination.
Its targets cannot grant consent, consume a suggestion, or sync preferences.
Authenticated surfaces open current review/settings controls instead.
"""
from __future__ import annotations
import re
from typing import Any
from .notify import STALE_ACTION_MESSAGE
REPLACED_CONTROL_MESSAGE = (
"This older control has been replaced. Review the current options below. "
"Nothing was changed."
)
def handle_action(target: str, **_legacy_options: Any) -> dict[str, Any]:
"""Never read the old ledger or treat its data as authority, even in the CLI."""
match = re.fullmatch(
r"wa:(share|review|install|update|view|view_changes|view_portal|not_now|mute):"
r"[A-Za-z0-9_-]{1,128}(?::(1d|1w|30d|forever|off))?",
target,
)
if match is None or (match[2] and match[1] != "mute"):
return {"ok": False, "stale": True, "message": STALE_ACTION_MESSAGE}
action = match[1]
command = (
"mute"
if action == "mute"
else "browse"
if action in {"view", "view_changes", "view_portal"}
else "inbox"
)
return {
"ok": True,
"action": action,
"command": command,
"requires_fresh_consent": True,
"open_mute_settings": command == "mute",
"message": REPLACED_CONTROL_MESSAGE,
"next": f"hermes wisdom {command}",
}
def current_action_view(target, service, context):
"""Called after adapter authentication; no legacy-selected identity is used."""
from gateway.wisdom_command import WisdomAction, WisdomCommandController, WisdomView
result = handle_action(target)
if not result["ok"]:
return WisdomView("Collective Wisdom", result["message"])
view = WisdomCommandController().execute(result["command"], service, context)
if result["command"] == "inbox" and not context.is_group and not view.items:
view.actions.extend([
WisdomAction(
"Browse team skills", "browse", local_command="/wisdom browse"
),
WisdomAction(
"Review local candidates",
"candidates",
local_command="/wisdom candidates",
),
])
view.notice = "\n".join(filter(None, (view.notice, result["message"])))
return view
def current_install_view(target, service, context):
"""An unversioned legacy click requests review, never approves latest bytes."""
from gateway.wisdom_command import WisdomAction, WisdomCommandController, WisdomView
match = re.fullmatch(
r"wi:(plan|confirm):(install|update):[A-Za-z0-9_-]{1,128}", target,
)
if match is None:
raise ValueError("Invalid Collective Wisdom action.")
if match[1] == "confirm":
return WisdomView(
"Review required", REPLACED_CONTROL_MESSAGE,
actions=[WisdomAction(
"Browse team skills", "browse", local_command="/wisdom browse",
)],
)
operation, reference = match[2], target.rsplit(":", 1)[1]
controller = WisdomCommandController()
if context.is_group:
return controller.execute(f"{operation} {reference}", service, context)
return controller.review_install(service, reference, kind=operation, context=context)
+198
View File
@@ -0,0 +1,198 @@
"""Agent invocation for agent-led review tasks.
Prompts live as Markdown files under ``prompts/`` at the repository root so
they can be reviewed and edited without touching code. Each task is a
one-shot auxiliary model call with strict JSON output that is validated by
:mod:`hermes_wisdom.agent_led.schemas`.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any, Callable
from pydantic import BaseModel
from .privacy import model_safe_data
from .schemas import (
CandidateReviewResult,
RecipientRecommendation,
SchemaRejected,
SharePackage,
parse_candidate_review,
parse_recipient_recommendation,
parse_share_package,
)
logger = logging.getLogger(__name__)
PROMPTS_DIR = Path(__file__).resolve().parents[2] / "prompts"
CANDIDATE_REVIEW_PROMPT = "wisdom_candidate_review.md"
RECIPIENT_RECOMMENDATION_PROMPT = "wisdom_recipient_recommendation.md"
SHARE_PACKAGING_PROMPT = "wisdom_share_packaging.md"
ModelCall = Callable[[list[dict[str, str]], dict[str, Any]], str]
def session_model_call(runtime, *, name: str, max_tokens: int = 6000) -> ModelCall:
"""Bounded tool-free work on the selected conversation's model route."""
def call(messages, schema):
from agent.auxiliary_client import call_llm, extract_content_or_reasoning
if not runtime.get("model") or not runtime.get("provider"):
raise ValueError("Wisdom work requires an active session model")
response = call_llm(
provider=runtime["provider"],
model=runtime["model"],
main_runtime=runtime,
tools=[],
messages=messages,
temperature=0,
timeout=45,
max_tokens=max_tokens,
extra_body={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": name,
"strict": True,
"schema": schema,
},
}
},
)
choices = getattr(response, "choices", []) or []
if choices and getattr(choices[0].message, "tool_calls", None):
raise ValueError("Wisdom work requested disallowed tools")
return extract_content_or_reasoning(response)
return call
def load_prompt(name: str) -> str:
path = PROMPTS_DIR / name
return path.read_text(encoding="utf-8")
def _default_model_call(messages: list[dict[str, str]], schema: dict[str, Any]) -> str:
from agent.auxiliary_client import call_llm, extract_content_or_reasoning
response = call_llm(
task="background_review",
messages=messages,
temperature=0,
max_tokens=2500,
timeout=45,
tools=[],
extra_body={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": str(schema.get("title") or "wisdom_agent_led"),
"strict": True,
"schema": schema,
},
}
},
)
return extract_content_or_reasoning(response).strip()
def _run(
prompt_name: str,
payload: dict[str, Any],
model: type[BaseModel],
parser: Callable[[str], Any],
*,
model_call: ModelCall | None = None,
max_attempts: int = 2,
) -> Any:
call = model_call or _default_model_call
schema = model.model_json_schema()
messages = [
{
"role": "system",
"content": load_prompt(prompt_name)
+ "\nReturn one JSON object matching this exact schema:\n"
+ json.dumps(schema, ensure_ascii=True),
},
{
"role": "user",
"content": json.dumps(
model_safe_data(payload), sort_keys=True, ensure_ascii=True, default=str
),
},
]
last_error: Exception | None = None
for attempt in range(1, max_attempts + 1):
raw = call(messages, schema)
try:
return parser(raw)
except SchemaRejected as exc:
last_error = exc
logger.warning(
"Agent-led %s output rejected on attempt %d: %s",
prompt_name,
attempt,
exc,
)
messages = [
*messages[:2],
{"role": "assistant", "content": model_safe_data(raw)},
{
"role": "user",
"content": (
"Your previous output failed validation: "
+ json.dumps(
[
{"type": error.get("type"), "loc": error.get("loc")}
for error in exc.errors
],
default=str,
)[:2000]
+ ". Return corrected strict JSON only."
),
},
]
assert last_error is not None
raise last_error
def review_candidates(
payload: dict[str, Any], *, model_call: ModelCall | None = None
) -> CandidateReviewResult:
return _run(
CANDIDATE_REVIEW_PROMPT,
payload,
CandidateReviewResult,
parse_candidate_review,
model_call=model_call,
)
def recommend_to_recipient(
payload: dict[str, Any], *, model_call: ModelCall | None = None
) -> RecipientRecommendation:
return _run(
RECIPIENT_RECOMMENDATION_PROMPT,
payload,
RecipientRecommendation,
parse_recipient_recommendation,
model_call=model_call,
)
def package_for_share(
payload: dict[str, Any], *, model_call: ModelCall | None = None
) -> SharePackage:
return _run(
SHARE_PACKAGING_PROMPT,
payload,
SharePackage,
parse_share_package,
model_call=model_call,
)
+264
View File
@@ -0,0 +1,264 @@
"""Build the structured evidence payload for the weekly agent-led review.
Only real invocation evidence from the local Wisdom ledger is used. Bundled
and hub-installed skills, managed org mirrors and archived skills are
excluded before the agent ever sees them.
"""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from hermes_constants import get_skills_dir
from tools.skill_usage import is_bundled, is_hub_installed
from .. import qualification as _qualification
from ..store import WisdomStore
from .history import SuggestionHistory
from .policy import AgentLedPolicy
logger = logging.getLogger(__name__)
EXCLUDED_ROOTS = frozenset({"_org", "_wisdom", ".archive", ".hub"})
@dataclass
class SkillEvidence:
skill_name: str
path: str
description: str
content_hash: str
window_days: int
invocation_count: int
days_used: int
last_used_day: str | None
usage_by_day: dict[str, int]
required_environment_variables: list[str] = field(default_factory=list)
required_commands: list[str] = field(default_factory=list)
scripts: list[str] = field(default_factory=list)
references: list[str] = field(default_factory=list)
frontmatter: dict[str, Any] = field(default_factory=dict)
supporting_signals: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
value = asdict(self)
value.pop("path", None)
# Arbitrary frontmatter metadata can contain deployment configuration.
value.pop("frontmatter", None)
return value
@dataclass
class EvidencePayload:
window_days: int
window_start: str
window_end: str
min_aggregate_count: int
organization: dict[str, Any]
candidates: list[SkillEvidence]
excluded: dict[str, list[str]]
def as_dict(self) -> dict[str, Any]:
return {
"window_days": self.window_days,
"window_start": self.window_start,
"window_end": self.window_end,
"min_aggregate_count": self.min_aggregate_count,
"organization": self.organization,
"candidates": [c.as_dict() for c in self.candidates],
"excluded": self.excluded,
}
def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
return [value.strip()] if value.strip() else []
if isinstance(value, (list, tuple)):
return [str(item).strip() for item in value if str(item).strip()]
return []
def read_frontmatter(skill_path: Path) -> dict[str, Any]:
try:
from ..package import _frontmatter
value = _frontmatter(skill_path)
return dict(value) if isinstance(value, dict) else {}
except Exception:
return {}
def dependency_hints(frontmatter: dict[str, Any]) -> tuple[list[str], list[str]]:
hermes_meta = (frontmatter.get("metadata") or {}).get("hermes") or {}
env = _string_list(
frontmatter.get("required_environment_variables")
) or _string_list(hermes_meta.get("required_environment_variables"))
commands = _string_list(frontmatter.get("required_commands")) or _string_list(
hermes_meta.get("required_commands")
)
return env, commands
def _listing(root: Path, sub: str) -> list[str]:
folder = root / sub
if not folder.is_dir():
return []
return sorted(str(p.relative_to(root)) for p in folder.rglob("*") if p.is_file())[
:50
]
def is_excluded_path(path: Path, *, skills_root: Path | None = None) -> str | None:
"""Return an exclusion reason for paths outside the user's own skill tree."""
root = (skills_root or get_skills_dir()).resolve()
try:
relative = path.resolve().relative_to(root)
except (OSError, ValueError):
return "outside_skills_dir"
if relative.parts and relative.parts[0] in EXCLUDED_ROOTS:
return "managed_or_archived"
return None
def provenance_exclusion(
skill_name: str, path: Path, *, skills_root: Path | None = None
) -> str | None:
if is_bundled(skill_name):
return "bundled"
if is_hub_installed(skill_name):
return "hub_installed"
return is_excluded_path(path, skills_root=skills_root)
def windowed_usage(
store: WisdomStore,
*,
since_day: str,
timezone_name: str,
through_day: str | None = None,
) -> dict[str, dict[str, Any]]:
"""Per-skill day->count map for local skills used on/after ``since_day``."""
rows: dict[str, dict[str, Any]] = {}
with store.transaction() as db:
cursor = db.execute(
"SELECT s.id, s.canonical_path, u.day_local, u.use_count "
"FROM usage_day u JOIN local_skill s ON s.id=u.skill_id "
"WHERE s.deleted_at IS NULL AND s.source_kind='local' "
"AND u.timezone_name=? AND u.day_local>=? AND (? IS NULL OR u.day_local<=?) ORDER BY s.id, u.day_local",
(timezone_name, since_day, through_day, through_day),
)
for skill_id, canonical_path, day, count in cursor.fetchall():
entry = rows.setdefault(
str(skill_id), {"path": str(canonical_path), "days": {}}
)
entry["days"][str(day)] = int(count)
return rows
def build_evidence(
*,
store: WisdomStore,
policy: AgentLedPolicy,
history: SuggestionHistory | None,
organization: dict[str, Any] | None = None,
at: datetime | None = None,
skills_root: Path | None = None,
supporting_signals: dict[str, list[str]] | None = None,
) -> EvidencePayload:
now = (at or datetime.now(timezone.utc)).astimezone(timezone.utc)
profile_tz, timezone_name = _qualification._profile_timezone()
today = now.astimezone(profile_tz).date()
start_day = today - timedelta(days=policy.window_days - 1)
usage = windowed_usage(
store,
since_day=start_day.isoformat(),
timezone_name=timezone_name,
through_day=today.isoformat(),
)
excluded: dict[str, list[str]] = {
"bundled": [],
"hub_installed": [],
"managed_or_archived": [],
"outside_skills_dir": [],
"missing": [],
"below_min_count": [],
"dismissed": [],
"previously_handled": [],
"recently_suggested": [],
}
candidates: list[SkillEvidence] = []
signals = supporting_signals or {}
for skill_id, entry in usage.items():
path = Path(entry["path"])
skill_name = path.name
if not path.is_dir():
excluded["missing"].append(skill_name)
continue
reason = provenance_exclusion(skill_name, path, skills_root=skills_root)
if reason:
excluded[reason].append(skill_name)
continue
days: dict[str, int] = entry["days"]
total = sum(days.values())
if total < policy.min_aggregate_count:
excluded["below_min_count"].append(skill_name)
continue
content_hash, _tree = _qualification.snapshot_tree(path)
if history is not None and history.is_suppressed(
skill_name, content_hash, at=now
):
excluded["dismissed"].append(skill_name)
continue
if history is not None and history.previously_handled(skill_name, content_hash):
excluded["previously_handled"].append(skill_name)
continue
if history is not None and history.recently_suggested(
skill_name,
content_hash,
cooldown_days=policy.resuggest_cooldown_days,
at=now,
):
excluded["recently_suggested"].append(skill_name)
continue
frontmatter = read_frontmatter(path)
env, commands = dependency_hints(frontmatter)
candidates.append(
SkillEvidence(
skill_name=skill_name,
path=str(path),
description=str(frontmatter.get("description") or "").strip(),
content_hash=content_hash,
window_days=policy.window_days,
invocation_count=total,
days_used=len(days),
last_used_day=max(days) if days else None,
usage_by_day=dict(sorted(days.items())),
required_environment_variables=env,
required_commands=commands,
scripts=_listing(path, "scripts"),
references=_listing(path, "references"),
frontmatter={
k: v
for k, v in frontmatter.items()
if k in {"name", "description", "version", "platforms", "metadata"}
},
supporting_signals=list(signals.get(skill_name, [])),
)
)
candidates.sort(key=lambda c: (-c.invocation_count, -c.days_used, c.skill_name))
return EvidencePayload(
window_days=policy.window_days,
window_start=start_day.isoformat(),
window_end=today.isoformat(),
min_aggregate_count=policy.min_aggregate_count,
organization=dict(organization or {}),
candidates=candidates,
excluded={k: sorted(v) for k, v in excluded.items()},
)
+214
View File
@@ -0,0 +1,214 @@
"""Local suggestion history: dismissals, mutes, and suggested-at records.
Persisted as JSON under ``$HERMES_HOME/wisdom/agent_led_history.json``. The
Gateway is the authority for cross-device state once it exposes a
suggestion-history/mute endpoint; until then, calls are recorded locally and
flagged ``pending_gateway`` so a later sync can replay them.
"""
from __future__ import annotations
import json
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_LOCK = threading.Lock()
FILE_NAME = "agent_led_history.json"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat()
def _parse(value: Any) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def history_path() -> Path:
try:
from hermes_constants import get_hermes_home
root = Path(get_hermes_home())
except Exception:
root = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
return root / "wisdom" / FILE_NAME
class SuggestionHistory:
"""Small JSON-backed ledger; every mutation is atomic on disk."""
def __init__(self, path: Path | None = None) -> None:
self.path = path or history_path()
self._data = self._load()
# -- persistence -------------------------------------------------------
def _load(self) -> dict[str, Any]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
raw = {}
if not isinstance(raw, dict):
raw = {}
raw.setdefault("version", 1)
raw.setdefault("dismissals", {})
raw.setdefault("suggested", {})
raw.setdefault("handled", {})
raw.setdefault("mutes", {})
raw.setdefault("pending_gateway", [])
return raw
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8")
os.replace(tmp, self.path)
try:
os.chmod(self.path, 0o600)
except OSError:
pass
def _queue_gateway(self, kind: str, payload: dict[str, Any]) -> None:
self._data["pending_gateway"].append(
{"kind": kind, "payload": payload, "queued_at": _iso(_now())}
)
# -- dismissals ------------------------------------------------------
def record_dismissal(
self,
skill_name: str,
content_hash: str,
*,
suppression_days: int = 30,
at: datetime | None = None,
client: Any = None,
) -> dict[str, Any]:
when = at or _now()
record = {
"content_hash": content_hash,
"dismissed_at": _iso(when),
"suppressed_until": _iso(when + timedelta(days=suppression_days)),
}
with _LOCK:
self._data["dismissals"][skill_name] = record
synced = self._push_gateway(client, "record_suggestion_dismissal", {
"skill_name": skill_name, **record,
})
if not synced:
self._queue_gateway("dismissal", {"skill_name": skill_name, **record})
self._save()
return {**record, "gateway": "synced" if synced else "pending_gateway"}
def is_suppressed(self, skill_name: str, content_hash: str, *, at: datetime | None = None) -> bool:
"""True while a dismissal for this exact content hash is still in force."""
when = at or _now()
record = self._data["dismissals"].get(skill_name)
if not isinstance(record, dict):
return False
if record.get("content_hash") != content_hash:
return False # content changed materially; eligible again
until = _parse(record.get("suppressed_until"))
return until is not None and when < until
# -- suggested / handled --------------------------------------------
def record_suggested(self, skill_name: str, content_hash: str, *, at: datetime | None = None) -> None:
with _LOCK:
self._data["suggested"][skill_name] = {
"content_hash": content_hash,
"suggested_at": _iso(at or _now()),
}
self._save()
def recently_suggested(
self, skill_name: str, content_hash: str, *, cooldown_days: int, at: datetime | None = None
) -> bool:
record = self._data["suggested"].get(skill_name)
if not isinstance(record, dict) or record.get("content_hash") != content_hash:
return False
when = _parse(record.get("suggested_at"))
return when is not None and (at or _now()) - when < timedelta(days=cooldown_days)
def record_handled(self, skill_name: str, content_hash: str, outcome: str, *, at: datetime | None = None) -> None:
"""Outcome is one of accepted / declined / published."""
with _LOCK:
self._data["handled"][skill_name] = {
"content_hash": content_hash,
"outcome": outcome,
"handled_at": _iso(at or _now()),
}
self._save()
def previously_handled(self, skill_name: str, content_hash: str) -> bool:
record = self._data["handled"].get(skill_name)
return isinstance(record, dict) and record.get("content_hash") == content_hash
# -- mutes -----------------------------------------------------------
def record_mute(
self,
scope: str,
*,
days: int | None,
at: datetime | None = None,
client: Any = None,
) -> dict[str, Any]:
"""Mute proactive messages for ``scope`` (``'*'`` or a skill id)."""
when = at or _now()
record = {
"muted_at": _iso(when),
"muted_until": _iso(when + timedelta(days=days)) if days is not None else None,
}
with _LOCK:
self._data["mutes"][scope] = record
synced = self._push_gateway(client, "set_suggestion_mute", {"scope": scope, **record})
if not synced:
self._queue_gateway("mute", {"scope": scope, **record})
self._save()
return {**record, "gateway": "synced" if synced else "pending_gateway"}
def is_muted(self, scope: str = "*", *, at: datetime | None = None) -> bool:
when = at or _now()
for key in ("*", scope):
record = self._data["mutes"].get(key)
if not isinstance(record, dict):
continue
until = record.get("muted_until")
if until is None:
return True
parsed = _parse(until)
if parsed is not None and when < parsed:
return True
return False
# -- gateway ---------------------------------------------------------
@staticmethod
def _push_gateway(client: Any, method: str, payload: dict[str, Any]) -> bool:
fn = getattr(client, method, None) if client is not None else None
if not callable(fn):
return False
try:
fn(**payload)
return True
except Exception as exc:
logger.debug("Suggestion history: %s not synced (%s)", method, type(exc).__name__)
return False
def pending_gateway(self) -> list[dict[str, Any]]:
return list(self._data["pending_gateway"])
def snapshot(self) -> dict[str, Any]:
return json.loads(json.dumps(self._data))
+166
View File
@@ -0,0 +1,166 @@
"""Agent-guided Install flow with resumable step state.
The flow reads the package's declared requirements, detects prerequisites,
guides setup, runs the verification step, and reports success only after
verification passes. Step state is persisted under HERMES_HOME so a
half-finished install can be resumed on the next turn.
"""
from __future__ import annotations
import json
import os
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from .history import history_path
STEPS = ("plan", "prerequisites", "setup", "apply", "verify", "done")
def _flows_dir() -> Path:
return history_path().parent / "install_flows"
def detect_prerequisites(requirements: list[dict[str, Any]], *, env: dict[str, str] | None = None) -> list[dict[str, Any]]:
"""Check commands and env vars from a package's requirement list."""
environment = env if env is not None else dict(os.environ)
report: list[dict[str, Any]] = []
for item in requirements:
kind = str(item.get("kind") or "")
name = str(item.get("name") or "")
status: str
if kind == "command":
status = "present" if shutil.which(name) else "missing"
elif kind == "env_var":
status = "present" if environment.get(name) else "missing"
else:
status = "manual" # accounts, services, permissions need the user
report.append({**item, "status": status})
return report
class InstallFlow:
def __init__(self, flow_id: str | None = None, *, root: Path | None = None) -> None:
self.root = root or _flows_dir()
self.flow_id = flow_id or uuid.uuid4().hex
self.path = self.root / f"{self.flow_id}.json"
self.state: dict[str, Any] = self._load()
def _load(self) -> dict[str, Any]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
return raw if isinstance(raw, dict) else {}
except (OSError, ValueError):
return {}
def _save(self) -> None:
self.root.mkdir(parents=True, exist_ok=True)
self.state["updated_at"] = datetime.now(timezone.utc).isoformat()
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self.state, indent=2, sort_keys=True, default=str), encoding="utf-8")
os.replace(tmp, self.path)
@property
def step(self) -> str:
return str(self.state.get("step") or "plan")
@property
def installed(self) -> bool:
return bool(self.state.get("installed"))
@property
def files_installed(self) -> bool:
return bool((self.state.get("apply_result") or {}).get("installed"))
def start(self, *, skill_id: str, version: int | None, package: dict[str, Any]) -> dict[str, Any]:
self.state = {
"flow_id": self.flow_id,
"skill_id": skill_id,
"version": version,
"package": package,
"step": "prerequisites",
"installed": False,
"history": [{"step": "plan", "at": datetime.now(timezone.utc).isoformat()}],
}
self._save()
return self.summary()
def check_prerequisites(self, *, env: dict[str, str] | None = None) -> dict[str, Any]:
requirements = list((self.state.get("package") or {}).get("requirements") or [])
report = detect_prerequisites(requirements, env=env)
self.state["prerequisites"] = report
missing = [r for r in report if r["status"] != "present"]
self.state["step"] = "setup" if missing else "apply"
self._record()
return self.summary()
def mark_setup_complete(self, name: str) -> dict[str, Any]:
for item in self.state.get("prerequisites") or []:
if item.get("name") == name:
item["status"] = "present"
if not any(r["status"] != "present" for r in self.state.get("prerequisites") or []):
self.state["step"] = "apply"
self._record()
return self.summary()
def apply(self, applier: Callable[[dict[str, Any]], dict[str, Any]]) -> dict[str, Any]:
"""Run the existing managed install path (receipt plan + apply)."""
if self.step != "apply":
raise RuntimeError(f"cannot apply from step {self.step}")
result = applier(dict(self.state))
self.state["apply_result"] = result
self.state["step"] = "verify" if result.get("installed") is True else "apply"
self._record()
return self.summary()
def verify(self, runner: Callable[[str], tuple[bool, str]] | None = None) -> dict[str, Any]:
if self.step != "verify":
raise RuntimeError(f"cannot verify from step {self.step}")
step_text = str((self.state.get("package") or {}).get("verification_step") or "").strip()
if runner is None:
self.state["verification"] = {
"ok": False,
"approval_required": True,
"detail": "Approve verification separately through the existing tool permissions.",
}
self._record()
return self.summary()
ok, detail = runner(step_text) if step_text else (False, "package has no verification step")
self.state["verification"] = {"ok": ok, "detail": detail[:2000]}
if ok:
self.state["step"] = "done"
self.state["installed"] = True
self._record()
return self.summary()
def _record(self) -> None:
self.state.setdefault("history", []).append(
{"step": self.step, "at": datetime.now(timezone.utc).isoformat()}
)
self._save()
def summary(self) -> dict[str, Any]:
return {
"flow_id": self.flow_id,
"skill_id": self.state.get("skill_id"),
"version": self.state.get("version"),
"step": self.step,
"installed": self.installed,
"files_installed": self.files_installed,
"prerequisites": self.state.get("prerequisites", []),
"missing": [r for r in self.state.get("prerequisites", []) if r.get("status") != "present"],
"setup_instructions": (self.state.get("package") or {}).get("setup_instructions", []),
"credential_handoff": (self.state.get("package") or {}).get("credential_handoff", []),
"verification": self.state.get("verification"),
"message": (
"Installed and verified."
if self.installed
else "Files installed; setup verification is incomplete."
if self.files_installed
else "Not installed yet: setup and installation are still in progress."
),
}
+346
View File
@@ -0,0 +1,346 @@
"""Legacy recommendation fixtures and local JSON snapshots.
Runtime delivery uses WisdomMediation and its transactional receipt outbox.
These old event targets are not consent authority; compatibility handlers only
open fresh controls. The legacy sender is disabled to prevent duplicate paths.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Literal
from .schemas import CandidateRecommendation, RecipientRecommendation
from .templates import RenderedNotice, render_share, render_teammate, render_update
logger = logging.getLogger(__name__)
EventType = Literal[
"wisdom.agent.share_candidate",
"wisdom.agent.teammate_published",
"wisdom.agent.update_available",
]
STALE_ACTION_MESSAGE = (
"This suggestion has expired or was already handled. "
"Use /wisdom to browse current skills."
)
@dataclass(frozen=True)
class AllowedAction:
id: str
label: str
target: str # opaque; the adapter/CLI resolves it, never the agent
primary: bool = False
@dataclass
class RecommendationEvent:
event_type: EventType
organization_id: str
recipient_id: str
skill_id: str
skill_version: int | None
title: str
explanation: str
evidence_summary: str
safety_summary: str
allowed_actions: list[AllowedAction]
expires_at: str
dedup_key: str
rendering_hints: dict[str, Any] = field(default_factory=dict)
notice: RenderedNotice | None = None
def as_dict(self) -> dict[str, Any]:
return {
"event_type": self.event_type,
"organization_id": self.organization_id,
"recipient_id": self.recipient_id,
"skill_id": self.skill_id,
"skill_version": self.skill_version,
"title": self.title,
"explanation": self.explanation,
"evidence_summary": self.evidence_summary,
"safety_summary": self.safety_summary,
"allowed_actions": [
{"id": a.id, "label": a.label, "target": a.target, "primary": a.primary}
for a in self.allowed_actions
],
"expires_at": self.expires_at,
"dedup_key": self.dedup_key,
"rendering_hints": dict(self.rendering_hints),
"notice": self.notice.as_dict() if self.notice else None,
}
def is_expired(self, at: datetime | None = None) -> bool:
now = at or datetime.now(timezone.utc)
try:
return now >= datetime.fromisoformat(self.expires_at)
except ValueError:
return True
def make_dedup_key(*parts: Any) -> str:
digest = hashlib.sha256(
json.dumps([str(p) for p in parts], separators=(",", ":")).encode("utf-8")
).hexdigest()
return digest[:32]
def action_target(dedup_key: str, action_id: str) -> str:
"""Opaque target bound to one event; ``wa:`` marks agent-led callbacks."""
return f"wa:{action_id}:{dedup_key}"
def parse_action_target(target: str) -> tuple[str, str] | None:
parts = target.split(":", 2)
if len(parts) != 3 or parts[0] != "wa":
return None
return parts[1], parts[2]
def _expiry(ttl_hours: int, at: datetime | None) -> str:
now = at or datetime.now(timezone.utc)
return (now + timedelta(hours=ttl_hours)).isoformat()
def share_candidate_event(
rec: CandidateRecommendation,
*,
organization_id: str,
recipient_id: str,
checks_passed: bool | None = None,
failed_checks: list[str] | None = None,
ttl_hours: int = 24 * 7,
at: datetime | None = None,
) -> RecommendationEvent:
dedup = make_dedup_key("share", organization_id, recipient_id, rec.skill_name, rec.content_hash)
targets = {aid: action_target(dedup, aid) for aid in rec.allowed_actions}
notice = render_share(
editorial_name=rec.editorial_name,
description=rec.one_line_description,
count_7d=rec.evidence.invocation_count,
specific_work=rec.how_user_relied_on_it,
audience=rec.audience,
reason=rec.why_coworkers_benefit,
checks_passed=checks_passed,
failed_checks=failed_checks,
window_days=rec.evidence.window_days,
targets=targets,
)
return RecommendationEvent(
event_type="wisdom.agent.share_candidate",
organization_id=organization_id,
recipient_id=recipient_id,
skill_id=rec.skill_name,
skill_version=None,
title=notice.title,
explanation=rec.what_it_does,
evidence_summary=(
f"{rec.evidence.invocation_count} uses on {rec.evidence.days_used} days "
f"in the last {rec.evidence.window_days} days"
),
safety_summary="Local checks complete" if checks_passed is True else
"Local checks found issues" if checks_passed is False else "Local checks unavailable",
allowed_actions=[
AllowedAction(a.id, a.label, a.target or "", a.primary)
for a in notice.actions
if a.id in rec.allowed_actions
],
expires_at=_expiry(ttl_hours, at),
dedup_key=dedup,
rendering_hints={**notice.hints, "content_hash": rec.content_hash},
notice=notice,
)
def teammate_event(
rec: RecipientRecommendation,
*,
organization_id: str,
recipient_id: str,
popular_threshold: int = 10,
ttl_hours: int = 24 * 7,
at: datetime | None = None,
) -> RecommendationEvent:
if not rec.relevant:
raise ValueError("cannot build a teammate event for a non-relevant recommendation")
dedup = make_dedup_key("teammate", organization_id, recipient_id, rec.skill_id, rec.version)
targets = {aid: action_target(dedup, aid) for aid in rec.allowed_actions}
count = rec.evidence.invocation_count if rec.evidence else 0
notice = render_teammate(
editorial_name=rec.editorial_name or "",
outcome_one_liner=rec.outcome_one_liner or "",
publisher_name=rec.publisher_name or "",
specific_work=rec.publisher_uses_it_for or "",
count_7d=count,
recipient_reason=rec.why_it_helps_recipient or "",
installation_count=rec.installation_count,
popular_threshold=popular_threshold,
window_days=rec.evidence.window_days if rec.evidence else 7,
targets=targets,
)
return RecommendationEvent(
event_type="wisdom.agent.teammate_published",
organization_id=organization_id,
recipient_id=recipient_id,
skill_id=rec.skill_id,
skill_version=rec.version,
title=notice.title,
explanation=rec.outcome_one_liner or "",
evidence_summary=f"{count} uses by publisher in the last 7 days",
safety_summary=rec.safety_summary or "Security check complete",
allowed_actions=[
AllowedAction(a.id, a.label, a.target or "", a.primary)
for a in notice.actions
if a.id in rec.allowed_actions
],
expires_at=_expiry(ttl_hours, at),
dedup_key=dedup,
rendering_hints=dict(notice.hints),
notice=notice,
)
def update_event(
*,
organization_id: str,
recipient_id: str,
skill_id: str,
skill_name: str,
version: int,
summary: str,
reason: str,
setup_impact: str | None,
ttl_hours: int = 24 * 7,
at: datetime | None = None,
) -> RecommendationEvent:
dedup = make_dedup_key("update", organization_id, recipient_id, skill_id, version)
targets = {aid: action_target(dedup, aid) for aid in ("update", "view_changes", "mute")}
notice = render_update(
skill_name=skill_name, summary=summary, reason=reason, setup_impact=setup_impact, targets=targets
)
return RecommendationEvent(
event_type="wisdom.agent.update_available",
organization_id=organization_id,
recipient_id=recipient_id,
skill_id=skill_id,
skill_version=version,
title=notice.title,
explanation=summary,
evidence_summary="",
safety_summary=f"Setup impact: {setup_impact or 'None'}",
allowed_actions=[AllowedAction(a.id, a.label, a.target or "", a.primary) for a in notice.actions],
expires_at=_expiry(ttl_hours, at),
dedup_key=dedup,
rendering_hints=dict(notice.hints),
notice=notice,
)
# ---------------------------------------------------------------------------
# Delivery ledger: idempotent, retried, resolvable for stale buttons
# ---------------------------------------------------------------------------
class DeliveryError(RuntimeError):
pass
class DeliveryLedger:
"""JSON ledger keyed by dedup key; safe across retries and restarts."""
def __init__(self, path: Path) -> None:
self.path = path
self._lock = threading.Lock()
self._data = self._load()
def _load(self) -> dict[str, Any]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
raw = {}
return raw if isinstance(raw, dict) else {}
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8")
os.replace(tmp, self.path)
def get(self, dedup_key: str) -> dict[str, Any] | None:
value = self._data.get(dedup_key)
return dict(value) if isinstance(value, dict) else None
def delivered(self, dedup_key: str) -> bool:
record = self.get(dedup_key)
return bool(record and record.get("state") == "delivered")
def record(self, event: RecommendationEvent, *, state: str, detail: str | None = None) -> None:
with self._lock:
existing = self._data.get(event.dedup_key) or {}
self._data[event.dedup_key] = {
**existing,
"event": event.as_dict(),
"state": state,
"detail": detail,
"updated_at": datetime.now(timezone.utc).isoformat(),
"attempts": int(existing.get("attempts", 0)) + (1 if state != "delivered" else 0),
}
self._save()
def resolve_action(self, target: str, *, at: datetime | None = None) -> dict[str, Any]:
"""Resolve an opaque action target; stale/unknown targets get a clear message."""
parsed = parse_action_target(target)
if parsed is None:
return {"ok": False, "stale": True, "message": STALE_ACTION_MESSAGE}
action_id, dedup_key = parsed
record = self.get(dedup_key)
if not record:
return {"ok": False, "stale": True, "message": STALE_ACTION_MESSAGE}
event = record.get("event") or {}
actions = {a.get("id"): a for a in event.get("allowed_actions", [])}
try:
expired = (at or datetime.now(timezone.utc)) >= datetime.fromisoformat(event.get("expires_at", ""))
except ValueError:
expired = True
if record.get("state") == "acted" or expired or action_id not in actions:
return {"ok": False, "stale": True, "message": STALE_ACTION_MESSAGE}
return {"ok": True, "stale": False, "action": action_id, "event": event}
def mark_acted(self, dedup_key: str, action_id: str) -> bool:
"""Idempotent: returns False when the event was already acted on."""
with self._lock:
record = self._data.get(dedup_key)
if not record or record.get("state") == "acted":
return False
record["state"] = "acted"
record["acted_with"] = action_id
record["acted_at"] = datetime.now(timezone.utc).isoformat()
self._save()
return True
Sender = Callable[[RecommendationEvent], Any]
def deliver(
event: RecommendationEvent,
*,
sender: Sender,
ledger: DeliveryLedger,
retries: int = 3,
backoff_seconds: float = 1.0,
sleep: Callable[[float], None] = time.sleep,
now: datetime | None = None,
) -> dict[str, Any]:
"""Compatibility failure, never a second send path around native ownership."""
raise DeliveryError("Legacy Wisdom delivery is retired; use the profile-owned mediation scheduler")
+161
View File
@@ -0,0 +1,161 @@
"""Policy values for agent-led sharing.
Organization limits are authoritative. Missing or invalid server policy defers
proactive work; local defaults never bypass an unavailable policy service.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field, replace
from typing import Any, Mapping
logger = logging.getLogger(__name__)
DEFAULT_WINDOW_DAYS = 7
DEFAULT_MIN_AGGREGATE_COUNT = 3
DEFAULT_MAX_CANDIDATES = 3
DEFAULT_DISMISS_SUPPRESSION_DAYS = 30
DEFAULT_RESUGGEST_COOLDOWN_DAYS = 14
DEFAULT_POPULAR_INSTALL_THRESHOLD = 10
DEFAULT_REVIEW_INTERVAL_HOURS = 24 * 7
DEFAULT_DELIVERY_RETRIES = 3
DEFAULT_DELIVERY_BACKOFF_SECONDS = 1.0
DEFAULT_RECOMMENDATION_TTL_HOURS = 24 * 7
_INT_FLOORS = {
"window_days": 1,
"min_aggregate_count": 1,
"max_candidates": 0,
"dismiss_suppression_days": 1,
"resuggest_cooldown_days": 0,
"popular_install_threshold": 1,
"review_interval_hours": 1,
"delivery_retries": 0,
"recommendation_ttl_hours": 1,
}
@dataclass(frozen=True)
class AgentLedPolicy:
enabled: bool = True
window_days: int = DEFAULT_WINDOW_DAYS
min_aggregate_count: int = DEFAULT_MIN_AGGREGATE_COUNT
max_candidates: int = DEFAULT_MAX_CANDIDATES
dismiss_suppression_days: int = DEFAULT_DISMISS_SUPPRESSION_DAYS
resuggest_cooldown_days: int = DEFAULT_RESUGGEST_COOLDOWN_DAYS
popular_install_threshold: int = DEFAULT_POPULAR_INSTALL_THRESHOLD
review_interval_hours: int = DEFAULT_REVIEW_INTERVAL_HOURS
delivery_retries: int = DEFAULT_DELIVERY_RETRIES
delivery_backoff_seconds: float = DEFAULT_DELIVERY_BACKOFF_SECONDS
recommendation_ttl_hours: int = DEFAULT_RECOMMENDATION_TTL_HOURS
consecutive_day_usage_counts: bool = True
repeated_edits_count: bool = True
notification_defaults: dict[str, bool] = field(default_factory=lambda: {
"skill_ready_to_share": True, "teammate_published": True, "update_available": True,
})
source: str = "defaults"
extras: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"enabled": self.enabled,
"window_days": self.window_days,
"min_aggregate_count": self.min_aggregate_count,
"max_candidates": self.max_candidates,
"dismiss_suppression_days": self.dismiss_suppression_days,
"resuggest_cooldown_days": self.resuggest_cooldown_days,
"popular_install_threshold": self.popular_install_threshold,
"review_interval_hours": self.review_interval_hours,
"delivery_retries": self.delivery_retries,
"delivery_backoff_seconds": self.delivery_backoff_seconds,
"recommendation_ttl_hours": self.recommendation_ttl_hours,
"consecutive_day_usage_counts": self.consecutive_day_usage_counts,
"repeated_edits_count": self.repeated_edits_count,
"notification_defaults": dict(self.notification_defaults),
"source": self.source,
}
def _coerce_int(value: Any, fallback: int, floor: int) -> int:
try:
number = int(value)
except (TypeError, ValueError):
return fallback
return max(floor, number)
def _coerce_float(value: Any, fallback: float) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return fallback
return max(0.0, number)
def _apply(policy: AgentLedPolicy, values: Mapping[str, Any], *, source: str) -> AgentLedPolicy:
updates: dict[str, Any] = {}
if isinstance(values.get("enabled"), bool):
updates["enabled"] = values["enabled"]
for key, floor in _INT_FLOORS.items():
if key in values:
updates[key] = _coerce_int(values[key], getattr(policy, key), floor)
if "delivery_backoff_seconds" in values:
updates["delivery_backoff_seconds"] = _coerce_float(
values["delivery_backoff_seconds"], policy.delivery_backoff_seconds
)
known = set(_INT_FLOORS) | {"enabled", "delivery_backoff_seconds"}
extras = {k: v for k, v in values.items() if k not in known}
if extras:
updates["extras"] = {**policy.extras, **extras}
if updates:
updates["source"] = source
return replace(policy, **updates) if updates else policy
def _local_config_block() -> Mapping[str, Any]:
try:
from hermes_cli.config import load_config
wisdom = (load_config() or {}).get("wisdom") or {}
except Exception:
return {}
if not isinstance(wisdom, dict):
return {}
block = wisdom.get("agent_led")
if isinstance(block, bool):
return {"enabled": block}
return block if isinstance(block, dict) else {}
def load_policy(*, client: Any = None, local: Mapping[str, Any] | None = None) -> AgentLedPolicy:
"""Read the member-scoped policy when agent delivery has not been disabled."""
policy = AgentLedPolicy()
local_block = local if local is not None else _local_config_block()
policy = _apply(policy, local_block, source="local_config")
from ..mediation import delivery_mode
if not policy.enabled or delivery_mode() != "agent":
return replace(policy, enabled=False)
try:
from ..client import AgentLedPolicyResponse
server = client.agent_led_policy()
server = AgentLedPolicyResponse.model_validate(server)
if not client.display_org_id or server.org_id != client.display_org_id:
raise ValueError("Recommendation policy organization mismatch")
except Exception as exc:
logger.debug("Agent-led policy unavailable (%s); deferring", type(exc).__name__)
return replace(policy, enabled=False, source="server_unavailable")
return replace(
policy,
window_days=server.usage_evidence_window_days,
min_aggregate_count=server.min_aggregate_invocations,
max_candidates=server.max_recommendations_per_user_per_week,
dismiss_suppression_days=server.not_now_suppression_days,
popular_install_threshold=server.install_popularity_threshold,
consecutive_day_usage_counts=server.consecutive_day_usage_counts,
repeated_edits_count=server.repeated_edits_count,
notification_defaults=server.notification_defaults.model_dump(),
source="server_policy",
)
+42
View File
@@ -0,0 +1,42 @@
"""Minimize untrusted local material before auxiliary model egress."""
from __future__ import annotations
import re
from typing import Any
from agent.redact import redact_sensitive_text
_PRIVATE_LOCATIONS = (
re.compile(r"/(?:home|Users)/[^\s\"'<>]+"),
re.compile(r"[A-Za-z]:\\Users\\[^\s\"'<>]+", re.IGNORECASE),
re.compile(r"\b[a-z0-9.-]+\.(?:internal|corp|local|lan)\b", re.IGNORECASE),
re.compile(r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))(?:\.\d{1,3}){2,3}\b"),
)
def model_safe_text(value: str) -> str:
value = re.sub(
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)",
"[REDACTED PRIVATE KEY]", value, flags=re.DOTALL,
)
# If a secret is detected, remove the complete line, not a masked prefix
# and suffix intended for diagnostic logs.
lines = []
for line in value.splitlines(keepends=True):
redacted = redact_sensitive_text(line, force=True, redact_url_credentials=True)
lines.append("[REDACTED]\n" if redacted != line else line)
text = "".join(lines)
for pattern in _PRIVATE_LOCATIONS:
text = pattern.sub("[PRIVATE LOCATION]", text)
return text
def model_safe_data(value: Any) -> Any:
if isinstance(value, str):
return model_safe_text(value)
if isinstance(value, list):
return [model_safe_data(item) for item in value]
if isinstance(value, dict):
return {model_safe_text(str(key)): model_safe_data(item) for key, item in value.items()}
return value
+118
View File
@@ -0,0 +1,118 @@
"""Legacy recommendation preview renderers, not the runtime delivery path.
Old ``wa:`` targets only open current review/settings; they carry no consent.
New runtime cards use mediation_view with actor-bound native interactions.
"""
from __future__ import annotations
import html as _html
from typing import Any
from .notify import RecommendationEvent
PRODUCT_LABEL = "Hermes Collective Wisdom"
def action_dicts(event: RecommendationEvent) -> list[dict[str, Any]]:
"""Generic action shape shared by Desktop/Dashboard and tests."""
return [
{"label": a.label, "callback_data": a.target, "primary": a.primary}
for a in event.allowed_actions
]
def render_plain(event: RecommendationEvent) -> str:
notice = event.notice
body = notice.text if notice else f"{event.title}\n{event.explanation}"
if event.title == PRODUCT_LABEL:
# Share cards already open with the product label; do not repeat it.
body = "\n".join(notice.lines) if notice else event.explanation
return f"{PRODUCT_LABEL}\n\n{body}"
def render_telegram_html(event: RecommendationEvent) -> str:
"""HTML for Telegram; the editorial name is the emphasized line, never the product."""
notice = event.notice
lines = list(notice.lines) if notice else [event.explanation]
rendered: list[str] = []
for index, line in enumerate(lines):
escaped = _html.escape(line)
if index == 0 and line.strip():
rendered.append(f"<b>{escaped}</b>")
elif line.startswith(" "):
rendered.append(f"<i>{escaped.strip()}</i>")
elif line in {"Why we ask"}:
rendered.append(f"<b>{escaped}</b>")
else:
rendered.append(escaped)
controls = " ".join(
'<tg-button type="callback_data"'
+ (' style="primary"' if a.primary else "")
+ f' data="{_html.escape(a.target, quote=True)}">{_html.escape(a.label)}</tg-button>'
for a in event.allowed_actions
)
return (
f"<h3>{_html.escape(PRODUCT_LABEL)}</h3>"
+ (
"<p>"
if event.title == PRODUCT_LABEL
else f"<p><b>{_html.escape(event.title)}</b><br/><br/>"
)
+ "<br/>".join(rendered)
+ (f"<br/>{controls}" if controls else "")
+ "</p>"
)
def render_slack_blocks(event: RecommendationEvent) -> list[dict[str, Any]]:
notice = event.notice
lines = list(notice.lines) if notice else [event.explanation]
name = lines[0] if lines else event.skill_id
body = "\n".join(lines[1:]) if len(lines) > 1 else ""
blocks: list[dict[str, Any]] = [
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": (
f"_{PRODUCT_LABEL}_"
if event.title == PRODUCT_LABEL
else f"_{PRODUCT_LABEL}_ • {event.title}"
),
}
],
},
{"type": "header", "text": {"type": "plain_text", "text": name[:150], "emoji": True}},
]
if body.strip():
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": body[:2900]}})
elements = [
{
"type": "button",
"text": {"type": "plain_text", "text": a.label[:75], "emoji": True},
"action_id": f"hermes_wisdom_agent_{index}",
"value": a.target,
**({"style": "primary"} if a.primary else {}),
}
for index, a in enumerate(event.allowed_actions)
]
if elements:
blocks.append({"type": "actions", "elements": elements[:5]})
return blocks
def render_desktop(event: RecommendationEvent) -> dict[str, Any]:
"""Structured payload for the Desktop/Dashboard notification surface."""
return {
"product": PRODUCT_LABEL,
"event_type": event.event_type,
"title": event.title,
"skill_name": event.notice.lines[0] if event.notice and event.notice.lines else event.skill_id,
"lines": list(event.notice.lines) if event.notice else [event.explanation],
"actions": action_dicts(event),
"expires_at": event.expires_at,
"dedup_key": event.dedup_key,
"hints": dict(event.rendering_hints),
}
+367
View File
@@ -0,0 +1,367 @@
"""Strict schemas for agent-authored Collective Wisdom output.
Two agent tasks return structured JSON: candidate review (which of the
user's own skills to propose sharing) and recipient recommendation (whether a
teammate's published skill is worth an interruption). Both are validated
here with pydantic; invalid output is rejected, and a conservative repair
pass is attempted before giving up so a single malformed field does not
silently drop a whole review.
"""
from __future__ import annotations
import json
import re
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
SCHEMA_VERSION = 1
CANDIDATE_ACTIONS: tuple[str, ...] = ("share", "review", "not_now")
RECIPIENT_ACTIONS: tuple[str, ...] = ("install", "view", "mute")
UPDATE_ACTIONS: tuple[str, ...] = ("update", "view_changes", "mute")
_SECRET_SHAPES = (
re.compile(r"(?i)\b(?:sk-|gh[po]_|xox[abpr]-|AKIA|AIza)[A-Za-z0-9_-]{12,}"),
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['\"]?[A-Za-z0-9_\-/+]{16,}"),
)
class SchemaRejected(ValueError):
"""Raised when agent output cannot be validated or repaired."""
def __init__(self, message: str, *, errors: list[dict[str, Any]] | None = None) -> None:
super().__init__(message)
self.errors = errors or []
def _no_secret_shapes(value: str) -> str:
for pattern in _SECRET_SHAPES:
if pattern.search(value):
raise ValueError("text contains a credential-shaped string")
return value
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class UsageEvidence(_Strict):
"""Real invocation evidence; never estimated by the agent."""
window_days: int = Field(ge=1, le=90)
invocation_count: int = Field(ge=0)
days_used: int = Field(ge=0)
last_used_at: str | None = None
examples: list[str] = Field(default_factory=list, max_length=5)
@field_validator("examples")
@classmethod
def _examples_clean(cls, value: list[str]) -> list[str]:
return [_no_secret_shapes(item)[:200] for item in value]
class PortabilityNote(_Strict):
kind: Literal[
"env_var",
"command",
"account",
"service",
"permission",
"org_specific",
"path",
"other",
]
detail: str = Field(min_length=1, max_length=300)
action: Literal["remove", "generalize", "document", "keep"] = "document"
@field_validator("detail")
@classmethod
def _detail_clean(cls, value: str) -> str:
return _no_secret_shapes(value)
class CandidateRecommendation(_Strict):
"""One skill the agent proposes the owner share."""
skill_name: str = Field(min_length=1, max_length=120)
content_hash: str = Field(min_length=8, max_length=128)
editorial_name: str = Field(min_length=1, max_length=80)
one_line_description: str = Field(min_length=1, max_length=200)
what_it_does: str = Field(min_length=1, max_length=600)
how_user_relied_on_it: str = Field(min_length=1, max_length=600)
evidence: UsageEvidence
why_coworkers_benefit: str = Field(min_length=1, max_length=600)
audience: str = Field(min_length=1, max_length=120, description="team or organization")
portability: list[PortabilityNote] = Field(default_factory=list, max_length=20)
confidence: float = Field(ge=0.0, le=1.0)
allowed_actions: list[Literal["share", "review", "not_now"]] = Field(
default_factory=lambda: list(CANDIDATE_ACTIONS)
)
@field_validator(
"editorial_name",
"one_line_description",
"what_it_does",
"how_user_relied_on_it",
"why_coworkers_benefit",
"audience",
)
@classmethod
def _prose_clean(cls, value: str) -> str:
return _no_secret_shapes(value)
@field_validator("allowed_actions")
@classmethod
def _actions_complete(cls, value: list[str]) -> list[str]:
# The owner can always defer; the agent may not remove that choice.
if "not_now" not in value:
value = [*value, "not_now"]
return list(dict.fromkeys(value))
class CandidateReviewResult(_Strict):
"""Top-level output of the candidate review prompt."""
schema_version: Literal[1] = SCHEMA_VERSION
window_days: int = Field(ge=1, le=90)
considered: list[str] = Field(default_factory=list)
recommendations: list[CandidateRecommendation] = Field(default_factory=list, max_length=5)
nothing_to_recommend_reason: str | None = Field(default=None, max_length=400)
requires_confirmation: Literal[True] = True
@field_validator("recommendations")
@classmethod
def _unique_skills(cls, value: list[CandidateRecommendation]) -> list[CandidateRecommendation]:
seen: set[str] = set()
for item in value:
if item.skill_name in seen:
raise ValueError(f"duplicate recommendation for {item.skill_name}")
seen.add(item.skill_name)
return value
class RecipientRecommendation(_Strict):
"""Top-level output of the recipient recommendation prompt."""
schema_version: Literal[1] = SCHEMA_VERSION
skill_id: str = Field(min_length=1, max_length=128)
version: int = Field(ge=1)
relevant: bool
not_relevant_reason: str | None = Field(default=None, max_length=400)
editorial_name: str | None = Field(default=None, max_length=80)
outcome_one_liner: str | None = Field(default=None, max_length=200)
publisher_name: str | None = Field(default=None, max_length=120)
publisher_uses_it_for: str | None = Field(default=None, max_length=300)
evidence: UsageEvidence | None = None
why_it_helps_recipient: str | None = Field(default=None, max_length=400)
safety_summary: str | None = Field(default=None, max_length=200)
installation_count: int | None = Field(default=None, ge=0)
confidence: float = Field(ge=0.0, le=1.0)
allowed_actions: list[Literal["install", "view", "mute"]] = Field(
default_factory=lambda: list(RECIPIENT_ACTIONS)
)
installed: Literal[False] = False
@field_validator(
"editorial_name",
"outcome_one_liner",
"publisher_uses_it_for",
"why_it_helps_recipient",
"safety_summary",
)
@classmethod
def _prose_clean(cls, value: str | None) -> str | None:
return _no_secret_shapes(value) if value else value
@field_validator("allowed_actions")
@classmethod
def _mute_always_available(cls, value: list[str]) -> list[str]:
if "mute" not in value:
value = [*value, "mute"]
return list(dict.fromkeys(value))
def require_complete_when_relevant(self) -> "RecipientRecommendation":
if not self.relevant:
return self
missing = [
name
for name in (
"editorial_name",
"outcome_one_liner",
"publisher_name",
"publisher_uses_it_for",
"why_it_helps_recipient",
)
if not getattr(self, name)
]
if missing:
raise SchemaRejected(
"relevant recommendation is missing required fields: " + ", ".join(missing)
)
return self
class PackagingRequirement(_Strict):
kind: Literal["command", "account", "service", "permission", "env_var", "skill", "script"]
name: str = Field(min_length=1, max_length=120)
purpose: str = Field(min_length=1, max_length=300)
handoff: str | None = Field(
default=None,
max_length=300,
description="How the recipient obtains it; never the value itself.",
)
@field_validator("purpose", "handoff")
@classmethod
def _clean(cls, value: str | None) -> str | None:
return _no_secret_shapes(value) if value else value
class PackagedFile(_Strict):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=False)
path: str = Field(min_length=1, max_length=255)
content: str
generalized_from_original: bool = False
@field_validator("path")
@classmethod
def _safe_path(cls, value: str) -> str:
from ..package import _validate_package_path
_validate_package_path(value, source="proposed package")
return value
@field_validator("content")
@classmethod
def _content_clean(cls, value: str) -> str:
return _no_secret_shapes(value)
class SetupGuidance(_Strict):
"""Publisher-declared guidance, never execution authority or local evidence."""
requirements: list[PackagingRequirement] = Field(default_factory=list, max_length=40)
setup_instructions: list[str] = Field(default_factory=list, max_length=20)
credential_handoff: list[str] = Field(default_factory=list, max_length=20)
compatibility_limits: list[str] = Field(default_factory=list, max_length=20)
verification_step: str = Field(min_length=1, max_length=400)
removed_or_generalized: list[str] = Field(default_factory=list, max_length=40)
related_skills: list[str] = Field(default_factory=list, max_length=20)
@field_validator(
"setup_instructions", "credential_handoff", "compatibility_limits", "removed_or_generalized"
)
@classmethod
def _lines_clean(cls, value: list[str]) -> list[str]:
return [_no_secret_shapes(line)[:400] for line in value]
class SharePackage(SetupGuidance):
"""Output of the structured packaging task in the Share flow."""
schema_version: Literal[1] = SCHEMA_VERSION
skill_name: str = Field(min_length=1, max_length=120)
source_content_hash: str = Field(min_length=8, max_length=128)
editorial_name: str = Field(min_length=1, max_length=80)
plain_description: str = Field(min_length=1, max_length=400)
files: list[PackagedFile] = Field(min_length=1, max_length=64)
@field_validator("skill_name")
@classmethod
def _skill_name(cls, value: str) -> str:
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,119}", value):
raise ValueError("skill_name must be a single safe identifier")
return value
@field_validator("files")
@classmethod
def _has_skill_md(cls, value: list[PackagedFile]) -> list[PackagedFile]:
from ..package import _validate_package_path
keys = [_validate_package_path(item.path, source="proposed package")[1] for item in value]
if len(keys) != len(set(keys)):
raise ValueError("package contains colliding paths")
if not any(item.path == "SKILL.md" for item in value):
raise ValueError("package must include SKILL.md")
return value
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
def _loads_lenient(raw: str) -> Any:
text = raw.strip()
text = _FENCE.sub("", text).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise
def _repair(model: type[BaseModel], data: Any, errors: list[dict[str, Any]]) -> Any:
"""Conservative repair: drop offending list items, never invent content."""
if not isinstance(data, dict):
raise SchemaRejected("agent output is not a JSON object", errors=errors)
repaired = dict(data)
dropped = False
for error in errors:
loc = error.get("loc") or ()
if len(loc) >= 2 and isinstance(loc[1], int) and isinstance(repaired.get(loc[0]), list):
items = list(repaired[loc[0]])
if 0 <= loc[1] < len(items):
items.pop(loc[1])
repaired[loc[0]] = items
dropped = True
elif len(loc) == 1 and error.get("type") == "extra_forbidden":
repaired.pop(loc[0], None)
dropped = True
if not dropped:
raise SchemaRejected("agent output failed schema validation", errors=errors)
return repaired
def parse_model(model: type[BaseModel], raw: str | dict[str, Any], *, repair: bool = True) -> Any:
"""Validate ``raw`` against ``model``; attempt one repair pass; else reject."""
try:
data = raw if isinstance(raw, dict) else _loads_lenient(raw)
except (json.JSONDecodeError, TypeError) as exc:
raise SchemaRejected(f"agent output is not valid JSON: {exc}") from exc
try:
return model.model_validate(data)
except ValidationError as exc:
errors = exc.errors()
if not repair:
raise SchemaRejected("agent output failed schema validation", errors=errors) from exc
repaired = _repair(model, data, errors)
try:
return model.model_validate(repaired)
except ValidationError as exc:
raise SchemaRejected(
"agent output failed schema validation after repair", errors=exc.errors()
) from exc
def parse_candidate_review(raw: str | dict[str, Any]) -> CandidateReviewResult:
return parse_model(CandidateReviewResult, raw)
def parse_recipient_recommendation(raw: str | dict[str, Any]) -> RecipientRecommendation:
parsed: RecipientRecommendation = parse_model(RecipientRecommendation, raw)
return parsed.require_complete_when_relevant()
def parse_share_package(raw: str | dict[str, Any]) -> SharePackage:
return parse_model(SharePackage, raw, repair=False)
def json_schema(model: type[BaseModel]) -> dict[str, Any]:
return model.model_json_schema()
+38
View File
@@ -0,0 +1,38 @@
"""The existing, hash-covered setup document shared by publisher and recipient."""
from __future__ import annotations
from typing import Literal
from ..contract import canonical_json_bytes
from .schemas import SetupGuidance, SharePackage
SETUP_PATH = "refs/wisdom-setup.md"
_PREFIX = (
"# Setup and portability\n\n"
"Installing files does not authorize executing these instructions.\n\n```json\n"
)
_SUFFIX = "\n```\n"
class SetupDocument(SetupGuidance):
schema_version: Literal[1] = 1
execution_requires_user_approval: Literal[True] = True
def render_setup_document(package: SharePackage) -> str:
data = package.model_dump(mode="json", include=set(SetupGuidance.model_fields))
document = SetupDocument.model_validate(data)
return _PREFIX + canonical_json_bytes(document.model_dump(mode="json")).decode("utf-8") + _SUFFIX
def parse_setup_document(body: bytes) -> SetupDocument:
text = body.decode("utf-8")
if not text.startswith(_PREFIX) or not text.endswith(_SUFFIX):
raise ValueError("setup document has an unsupported format")
# No LLM repair or truncation of reviewed instructions at the recipient.
raw = text[len(_PREFIX):-len(_SUFFIX)]
document = SetupDocument.model_validate_json(raw, strict=True)
if canonical_json_bytes(document.model_dump(mode="json")).decode("utf-8") != raw:
raise ValueError("setup document is not the complete canonical guidance")
return document
+486
View File
@@ -0,0 +1,486 @@
"""Agent-guided Share flow.
``Share`` never publishes local files directly. A deterministic pre-pass
extracts requirements and scans for credential-shaped strings; a structured
agent packaging task then produces a portable package; the owner sees the
portability problems and package summary and must approve before the
existing publish path (``WisdomService.suggest`` -> review -> approve) is
invoked. Every step is persisted so the flow is resumable.
"""
from __future__ import annotations
import json
import logging
import os
import re
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from .agent import ModelCall, package_for_share
from .evidence import dependency_hints, read_frontmatter
from .history import history_path
from .schemas import SchemaRejected, SharePackage, PackagedFile
from ..contract import canonical_json_bytes, sha256_address
logger = logging.getLogger(__name__)
CREDENTIAL_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{30,}\b")),
("slack_token", re.compile(r"\bxox[abpr]-[A-Za-z0-9-]{10,}\b")),
("openai_style_key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b")),
(
"assignment",
re.compile(
r"(?i)\b(api[_-]?key|secret|token|password|passwd)\b\s*[:=]\s*['\"]?[A-Za-z0-9_\-/+]{16,}"
),
),
)
ORG_SPECIFIC_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("home_path", re.compile(r"/(?:home|Users)/[A-Za-z0-9._-]+")),
("internal_host", re.compile(r"\b[a-z0-9-]+\.(?:internal|corp|local|lan)\b")),
(
"private_ip",
re.compile(
r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}(?:\.\d{1,3})?\b"
),
),
)
TEXT_SUFFIXES = {
".md",
".txt",
".py",
".sh",
".json",
".yaml",
".yml",
".toml",
".cfg",
".ini",
".js",
".ts",
}
MAX_FILE_BYTES = 200_000
MAX_TREE_BYTES = 1_000_000
MAX_FILES = 64
STATES = (
"prepass",
"packaged",
"awaiting_approval",
"changes_requested",
"approved",
"submitted",
"cancelled",
)
def _flows_dir() -> Path:
return history_path().parent / "share_flows"
def _list_files(root: Path) -> list[dict[str, str]]:
if root.is_symlink() or not root.is_dir():
raise SchemaRejected("skill root must be a regular directory")
files: list[dict[str, str]] = []
total = 0
for path in sorted(root.rglob("*")):
if path.is_symlink():
raise SchemaRejected("symlinks cannot be included in packaging input")
if path.is_dir():
continue
if not path.is_file() or path.stat().st_nlink != 1:
raise SchemaRejected("packaging input must contain regular, unlinked files")
if path.suffix.lower() not in TEXT_SUFFIXES:
raise SchemaRejected(
"unsupported packaging file; review the source before sharing"
)
if path.stat().st_size > MAX_FILE_BYTES:
raise SchemaRejected("packaging input file exceeds its size limit")
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
raise SchemaRejected(
"packaging input must be readable UTF-8 text"
) from None
total += len(content.encode("utf-8"))
if total > MAX_TREE_BYTES or len(files) >= MAX_FILES:
raise SchemaRejected("packaging input exceeds its total size limit")
files.append({"path": path.relative_to(root).as_posix(), "content": content})
return files
def scan_credentials(files: list[dict[str, str]]) -> list[dict[str, Any]]:
"""Return credential-shaped findings; matches are never echoed back."""
findings: list[dict[str, Any]] = []
for item in files:
for number, line in enumerate(item["content"].splitlines(), start=1):
for kind, pattern in CREDENTIAL_PATTERNS:
if pattern.search(line):
findings.append({
"kind": kind,
"file": item["path"],
"line": number,
})
return findings
def scan_org_specific(files: list[dict[str, str]]) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
for item in files:
for number, line in enumerate(item["content"].splitlines(), start=1):
for kind, pattern in ORG_SPECIFIC_PATTERNS:
match = pattern.search(line)
if match:
findings.append({
"kind": kind,
"file": item["path"],
"line": number,
"token": match.group(0)[:80],
})
return findings
def _existing_security_scan(skill_path: Path) -> dict[str, Any] | None:
"""Reuse the repo's guard/evaluator scan when importable."""
try:
from ..service import _scan_summary
return _scan_summary(skill_path)
except Exception as exc: # pragma: no cover - optional dependency path
logger.debug("existing security scan unavailable: %s", type(exc).__name__)
return None
def prepass(skill_path: Path) -> dict[str, Any]:
from ..qualification import snapshot_tree
files = _list_files(skill_path)
frontmatter = read_frontmatter(skill_path)
env, commands = dependency_hints(frontmatter)
content_hash, _tree = snapshot_tree(skill_path)
return {
"skill_name": skill_path.name,
"source_content_hash": content_hash,
"frontmatter_requirements": {
"required_environment_variables": env,
"required_commands": commands,
},
"scripts": [f["path"] for f in files if f["path"].startswith("scripts/")],
"references": [f["path"] for f in files if f["path"].startswith("references/")],
"credential_findings": scan_credentials(files),
"org_specific_findings": scan_org_specific(files),
"existing_security_scan": _existing_security_scan(skill_path),
"files": files,
}
class ShareFlow:
"""Resumable state machine; one JSON file per flow under HERMES_HOME."""
def __init__(self, flow_id: str | None = None, *, root: Path | None = None) -> None:
self.root = root or _flows_dir()
self.flow_id = flow_id or uuid.uuid4().hex
if not re.fullmatch(r"[a-f0-9]{32}", self.flow_id):
raise ValueError("invalid share flow identity")
self.path = self.root / f"{self.flow_id}.json"
self.state: dict[str, Any] = self._load()
def _load(self) -> dict[str, Any]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
return raw if isinstance(raw, dict) else {}
except (OSError, ValueError):
return {}
def _save(self) -> None:
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
self.state["updated_at"] = datetime.now(timezone.utc).isoformat()
fd, temporary = tempfile.mkstemp(
prefix=f"{self.flow_id}-", suffix=".pending", dir=self.root
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(self.state, stream, indent=2, sort_keys=True, default=str)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path)
finally:
Path(temporary).unlink(missing_ok=True)
@property
def status(self) -> str:
return str(self.state.get("status") or "new")
# -- steps -------------------------------------------------------------
def start(self, skill_path: Path) -> dict[str, Any]:
result = prepass(skill_path)
self.state = {
"flow_id": self.flow_id,
"skill_name": result["skill_name"],
"skill_path": str(skill_path),
"source_content_hash": result["source_content_hash"],
"status": "prepass",
"prepass": {k: v for k, v in result.items() if k != "files"},
"published": False,
}
self._save()
return self.summary()
def package(
self,
*,
model_call: ModelCall | None = None,
organization: dict[str, Any] | None = None,
) -> dict[str, Any]:
if self.status not in {"prepass", "changes_requested"}:
raise RuntimeError(f"cannot package from state {self.status}")
skill_path = Path(self.state["skill_path"])
pre = prepass(skill_path)
if pre["source_content_hash"] != self.state["source_content_hash"]:
self.state["status"] = "prepass"
self.state["source_content_hash"] = pre["source_content_hash"]
self.state["prepass"] = {k: v for k, v in pre.items() if k != "files"}
payload = {
"skill_name": pre["skill_name"],
"source_content_hash": pre["source_content_hash"],
"files": pre["files"],
"prepass": {
k: v
for k, v in pre.items()
if k not in {"files", "existing_security_scan"}
},
"organization": organization or {},
"requested_changes": self.state.get("requested_changes"),
}
try:
package = package_for_share(payload, model_call=model_call)
package = normalize_generated_package(package)
except SchemaRejected as exc:
self.state["last_error"] = str(exc)
self._save()
raise
if (
package.source_content_hash != pre["source_content_hash"]
or package.skill_name != pre["skill_name"]
):
raise SchemaRejected(
"package does not reference the current source content hash"
)
leaked = scan_credentials([
{"path": f.path, "content": f.content} for f in package.files
])
if leaked:
raise SchemaRejected(
"package still contains credential-shaped strings", errors=leaked
)
self.state["package"] = package.model_dump(mode="json")
self.state["package_hash"] = package_hash(package)
self.state.pop("approved_package_hash", None)
self.state["status"] = "awaiting_approval"
self.state.pop("last_error", None)
self._save()
return self.summary()
def request_changes(self, note: str) -> dict[str, Any]:
if self.status != "awaiting_approval":
raise RuntimeError(f"cannot request changes from state {self.status}")
self.state["requested_changes"] = str(note).strip()[:2000]
self.state["status"] = "changes_requested"
self._save()
return self.summary()
def cancel(self) -> dict[str, Any]:
self.state["status"] = "cancelled"
self._save()
return self.summary()
def approve(
self, *, submit: Callable[[SharePackage, dict[str, Any]], dict[str, Any]]
) -> dict[str, Any]:
"""Explicit approval; only now is the existing publish path invoked."""
if self.status == "submitted":
return self.summary()
if self.status not in {"awaiting_approval", "approved"}:
raise RuntimeError("approval requires a packaged flow awaiting approval")
package = SharePackage.model_validate(self.state["package"])
if self.state.get("package_hash") != package_hash(package):
raise SchemaRejected("package changed; regenerate and review it again")
if (
self.status == "approved"
and self.state.get("approved_package_hash") != self.state["package_hash"]
):
raise SchemaRejected("approval does not match this package")
self.state["approved_package_hash"] = self.state["package_hash"]
self.state["status"] = "approved"
self._save()
result = submit(package, dict(self.state))
self.state["status"] = "submitted"
self.state["submission"] = result
self._save()
return self.summary()
# -- presentation ----------------------------------------------------
def summary(self) -> dict[str, Any]:
pre = self.state.get("prepass") or {}
package = self.state.get("package") or {}
problems: list[str] = []
for finding in pre.get("credential_findings") or []:
problems.append(
f"Credential-shaped {finding['kind']} in {finding['file']}:{finding['line']}"
)
for finding in pre.get("org_specific_findings") or []:
problems.append(
f"Organization-specific {finding['kind']} in {finding['file']}:{finding['line']}"
)
return {
"flow_id": self.flow_id,
"skill_name": self.state.get("skill_name"),
"status": self.status,
"source_content_hash": self.state.get("source_content_hash"),
"package_hash": self.state.get("package_hash"),
"prepared_review": (self.state.get("submission") or {}).get("prepared"),
"portability_problems": problems,
"package_summary": {
"editorial_name": package.get("editorial_name"),
"plain_description": package.get("plain_description"),
"files": [f["path"] for f in package.get("files", [])],
"requirements": package.get("requirements", []),
"setup_instructions": package.get("setup_instructions", []),
"credential_handoff": package.get("credential_handoff", []),
"compatibility_limits": package.get("compatibility_limits", []),
"verification_step": package.get("verification_step"),
"removed_or_generalized": package.get("removed_or_generalized", []),
"related_skills": package.get("related_skills", []),
}
if package
else None,
"next_actions": (
["approve", "request_changes", "cancel"]
if self.status == "awaiting_approval"
else []
),
"published": bool(self.state.get("published")),
}
def normalize_generated_package(package: SharePackage) -> SharePackage:
"""Materialize the generated metadata as part of the reviewable package."""
import yaml
from .setup_document import SETUP_PATH, render_setup_document
files = []
setup_path = SETUP_PATH
setup = render_setup_document(package)
for item in package.files:
if item.path == setup_path:
if item.content != setup:
raise SchemaRejected(
"refs/wisdom-setup.md is reserved for the reviewed setup metadata"
)
continue
if item.path == "SKILL.md" and not item.content.startswith("---"):
header = yaml.safe_dump(
{"name": package.skill_name, "description": package.plain_description},
sort_keys=False,
allow_unicode=True,
)
item = item.model_copy(
update={"content": "---\n" + header + "---\n" + item.content}
)
files.append(item)
files.append(PackagedFile(path=setup_path, content=setup))
return package.model_copy(update={"files": files})
def package_hash(package: SharePackage) -> str:
package = normalize_generated_package(package)
return sha256_address(canonical_json_bytes(package.model_dump(mode="json")))
def verify_staged_package(package: SharePackage, target: Path) -> None:
package = normalize_generated_package(package)
if target.is_symlink() or not target.is_dir():
raise SchemaRejected("staged package must be a regular directory")
expected = {item.path: item.content.encode("utf-8") for item in package.files}
found = {}
for path in target.rglob("*"):
if path.is_symlink():
raise SchemaRejected("staged package contains a symlink")
if path.is_dir():
continue
if not path.is_file() or path.stat().st_nlink != 1:
raise SchemaRejected("staged package contains an unsafe file")
relative = path.relative_to(target).as_posix()
if relative not in expected or path.stat().st_size != len(expected[relative]):
raise SchemaRejected("staged package contains changed or unexpected files")
found[relative] = path.read_bytes()
if found != expected:
raise SchemaRejected("staged package changed; review the exact bytes again")
def write_package_to_staging(package: SharePackage, staging_root: Path) -> Path:
"""Create a content-addressed snapshot without modifying any prior review."""
from ..package import verify_content_files
package = normalize_generated_package(
SharePackage.model_validate(package.model_dump(mode="json"))
)
verify_content_files(
[(item.path, "file", item.content.encode("utf-8")) for item in package.files],
require_manifest=False,
)
if scan_credentials([
{"path": item.path, "content": item.content} for item in package.files
]):
raise SchemaRejected("staged package contains credential-shaped strings")
staging_root.mkdir(parents=True, exist_ok=True, mode=0o700)
if staging_root.is_symlink():
raise SchemaRejected("staging root cannot be a symlink")
namespace = staging_root / package_hash(package).removeprefix("sha256:")
target = namespace / package.skill_name
if namespace.is_symlink():
raise SchemaRejected("staging namespace cannot be a symlink")
if namespace.exists():
verify_staged_package(package, target)
return target
with tempfile.TemporaryDirectory(prefix="package-", dir=staging_root) as temporary:
staged_namespace = Path(temporary) / "snapshot"
staged = staged_namespace / package.skill_name
staged.mkdir(parents=True, mode=0o700)
for item in package.files:
destination = staged / item.path
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
destination.write_bytes(item.content.encode("utf-8"))
destination.chmod(0o600)
try:
os.rename(staged_namespace, namespace)
except OSError:
if not namespace.exists():
raise
if namespace.is_symlink():
raise SchemaRejected("staging namespace cannot be a symlink")
verify_staged_package(package, target)
return target
def submit_via_service(
service: Any, staging_root: Path
) -> Callable[[SharePackage, dict[str, Any]], dict[str, Any]]:
"""Prepare approved generated bytes locally; never upload or publish here."""
def _submit(package: SharePackage, _state: dict[str, Any]) -> dict[str, Any]:
if _state.get("approved_package_hash") != package_hash(package):
raise SchemaRejected("package has not been approved for local preparation")
prepared = service.prepare_share_package(
package, source_path=Path(_state["skill_path"]), staging_root=staging_root
)
return {"prepared": prepared, "published": False, "network_submission": False}
return _submit
+308
View File
@@ -0,0 +1,308 @@
"""Fixed copy templates for agent-authored Collective Wisdom notifications.
The hierarchy, headers, check lines and button labels are fixed here; the
agent supplies only the bracketed prose. Rendering is plain text with a
structured ``actions`` list so each platform adapter (Telegram, Slack,
Desktop) can attach native buttons. The word "Pass" is deliberately never
emitted: safety checks render as ``✓``/``✗`` lines or a single "Yes"/"No".
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
SHARE_HEADER = "Hermes Collective Wisdom"
SHARE_WHY_HEADER = "Why we ask"
SHARE_SAFE_YES = "Checks complete: no issues detected."
SHARE_SAFE_NO = "Checks found issues. Review the details before sharing."
SHARE_SAFE_UNAVAILABLE = "Checks unavailable. Review is required before sharing."
SHARE_QUESTION = "Would you like to share it?"
SHARE_CHECK_LINES: tuple[str, ...] = (
"No profanity or abusive language",
"No hate or harassment",
"No sexual or graphic content",
"No detected credentials or private keys",
)
SHARE_BUTTONS: tuple[tuple[str, str], ...] = (
("not_now", "Not now"),
("review", "Review"),
("share", "Share"),
)
TEAMMATE_HEADER = "New skill from your team"
TEAMMATE_SECURITY_LINE = (
"✓ Security check complete (credentials, private keys, organization policy)"
)
TEAMMATE_POPULAR_LINE = "Popular with your team: {count}+ installations"
TEAMMATE_BUTTONS: tuple[tuple[str, str], ...] = (
("mute", "Mute"),
("view", "View"),
("install", "Install"),
)
PUBLISHED_OPEN_HEADER = "Published to {organization}."
PUBLISHED_OPEN_BODY = "Your teammates can now find and install this skill."
PUBLISHED_MODERATED_HEADER = "Sent for review."
PUBLISHED_MODERATED_BODY = (
"It is not available to your organization yet. "
"You will be notified when the review is complete."
)
PUBLISHED_BUTTONS: tuple[tuple[str, str], ...] = (("view_portal", "View in Portal"),)
UPDATE_HEADER = "Update available for {skill_name}"
UPDATE_MATTERS = "This matters for your work because {reason}."
UPDATE_SETUP_IMPACT = "Setup impact: {impact}"
UPDATE_BUTTONS: tuple[tuple[str, str], ...] = (
("mute", "Mute"),
("view_changes", "View changes"),
("update", "Update"),
)
MUTE_OPTIONS: tuple[tuple[str, str, int | None], ...] = (
("1d", "1 day", 1),
("1w", "1 week", 7),
("30d", "30 days", 30),
("forever", "Forever", None),
)
FORBIDDEN_WORDS: tuple[str, ...] = ("Pass",)
FORBIDDEN_LABELS: tuple[str, ...] = ("New notification",)
@dataclass(frozen=True)
class Action:
id: str
label: str
primary: bool = False
target: str | None = None # opaque callback/URL target bound by the adapter
@dataclass(frozen=True)
class RenderedNotice:
kind: Literal["share", "teammate", "published", "update", "mute_options"]
title: str
lines: list[str]
actions: list[Action]
hints: dict[str, Any] = field(default_factory=dict)
@property
def text(self) -> str:
return "\n".join([self.title, *self.lines])
def as_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"title": self.title,
"lines": list(self.lines),
"text": self.text,
"actions": [
{"id": a.id, "label": a.label, "primary": a.primary, "target": a.target}
for a in self.actions
],
"hints": dict(self.hints),
}
def _actions(
spec: tuple[tuple[str, str], ...], *, primary: str, targets: dict[str, str] | None
) -> list[Action]:
targets = targets or {}
return [
Action(id=aid, label=label, primary=(aid == primary), target=targets.get(aid))
for aid, label in spec
]
def _require_text(value: Any, name: str) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{name} is required")
return text
def _require_count(value: Any, name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a real non-negative integer")
return value
def assert_clean_copy(text: str) -> None:
"""Fail fast if fixed copy or agent prose reintroduces forbidden wording."""
for word in FORBIDDEN_WORDS:
for token in text.replace("\n", " ").split(" "):
if token.strip(".,:;!?()") == word:
raise ValueError(f"forbidden word in notification copy: {word!r}")
for label in FORBIDDEN_LABELS:
if label in text:
raise ValueError(f"forbidden label in notification copy: {label!r}")
def render_share(
*,
editorial_name: str,
description: str,
count_7d: int,
specific_work: str,
audience: str,
reason: str,
checks_passed: bool | None = None,
failed_checks: list[str] | None = None,
window_days: int = 7,
targets: dict[str, str] | None = None,
) -> RenderedNotice:
name = _require_text(editorial_name, "editorial_name")
desc = _require_text(description, "description")
n = _require_count(count_7d, "count_7d")
work = _require_text(specific_work, "specific_work")
who = _require_text(audience, "audience")
why = _require_text(reason, "reason").rstrip(".")
lines = [
name,
f" {desc}",
"",
SHARE_WHY_HEADER,
(
f"You used this skill {n} times in the last {window_days} days. "
f"It has helped with {work}. I think it could help {who} because {why}."
),
"",
SHARE_SAFE_YES if checks_passed is True else SHARE_SAFE_NO
if checks_passed is False else SHARE_SAFE_UNAVAILABLE,
]
failed = set(failed_checks or [])
for check in SHARE_CHECK_LINES:
if check in failed:
lines.append(f"⚠ Review: {check.removeprefix('No ')}")
elif checks_passed is True:
lines.append(f"✓ {check}")
else:
lines.append(f"➖ Unavailable: {check.removeprefix('No ')}")
lines.append("These checks are not a security certification.")
lines += ["", SHARE_QUESTION]
notice = RenderedNotice(
kind="share",
title=SHARE_HEADER,
lines=lines,
actions=_actions(SHARE_BUTTONS, primary="share", targets=targets),
hints={"name_emphasis": "editorial_name", "description_indent": True},
)
assert_clean_copy(notice.text)
return notice
def render_teammate(
*,
editorial_name: str,
outcome_one_liner: str,
publisher_name: str,
specific_work: str,
count_7d: int,
recipient_reason: str,
installation_count: int | None = None,
popular_threshold: int = 10,
window_days: int = 7,
targets: dict[str, str] | None = None,
) -> RenderedNotice:
name = _require_text(editorial_name, "editorial_name")
outcome = _require_text(outcome_one_liner, "outcome_one_liner")
publisher = _require_text(publisher_name, "publisher_name")
work = _require_text(specific_work, "specific_work")
n = _require_count(count_7d, "count_7d")
why = _require_text(recipient_reason, "recipient_reason").rstrip(".")
lines = [
name,
outcome,
f"Published by {publisher}",
"",
(
f"{publisher} uses this for {work}. It was used {n} times in the last "
f"{window_days} days. I think it could help you with {why}."
),
"",
TEAMMATE_SECURITY_LINE,
]
popular = installation_count is not None and installation_count >= popular_threshold
if popular:
lines.append(TEAMMATE_POPULAR_LINE.format(count=popular_threshold))
notice = RenderedNotice(
kind="teammate",
title=TEAMMATE_HEADER,
lines=lines,
actions=_actions(TEAMMATE_BUTTONS, primary="install", targets=targets),
hints={"name_emphasis": "editorial_name", "name_size": "largest", "popular": popular},
)
assert_clean_copy(notice.text)
return notice
def render_published(
*,
organization_name: str,
moderated: bool,
targets: dict[str, str] | None = None,
) -> RenderedNotice:
if moderated:
title, body = PUBLISHED_MODERATED_HEADER, PUBLISHED_MODERATED_BODY
else:
org = _require_text(organization_name, "organization_name")
title, body = PUBLISHED_OPEN_HEADER.format(organization=org), PUBLISHED_OPEN_BODY
notice = RenderedNotice(
kind="published",
title=title,
lines=[body],
actions=_actions(PUBLISHED_BUTTONS, primary="view_portal", targets=targets),
hints={"moderated": moderated},
)
assert_clean_copy(notice.text)
return notice
def render_update(
*,
skill_name: str,
summary: str,
reason: str,
setup_impact: str | None = None,
targets: dict[str, str] | None = None,
) -> RenderedNotice:
name = _require_text(skill_name, "skill_name")
text = _require_text(summary, "summary")
why = _require_text(reason, "reason").rstrip(".")
impact = str(setup_impact or "").strip() or "None"
notice = RenderedNotice(
kind="update",
title=UPDATE_HEADER.format(skill_name=name),
lines=[
text,
UPDATE_MATTERS.format(reason=why),
UPDATE_SETUP_IMPACT.format(impact=impact),
],
actions=_actions(UPDATE_BUTTONS, primary="update", targets=targets),
)
assert_clean_copy(notice.text)
return notice
def render_mute_options(*, targets: dict[str, str] | None = None) -> RenderedNotice:
targets = targets or {}
actions = [
Action(id=f"mute:{key}", label=label, target=targets.get(key))
for key, label, _days in MUTE_OPTIONS
]
notice = RenderedNotice(
kind="mute_options",
title="Mute suggestions for how long?",
lines=["Muting only pauses proactive messages. You can still browse and install."],
actions=actions,
)
assert_clean_copy(notice.text)
return notice
def mute_duration_days(key: str) -> int | None:
"""Return the day count for a mute option key; ``None`` means forever."""
for option_key, _label, days in MUTE_OPTIONS:
if option_key == key:
return days
raise KeyError(key)
+887
View File
@@ -0,0 +1,887 @@
"""Bounded, schema-validated Gateway client for Collective Wisdom."""
from __future__ import annotations
import base64
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from urllib.parse import quote
import requests
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, model_validator
from tools.skills_sync_client import (
ObjectSet,
SyncClient,
)
from tools.skills_sync_client import (
resolve_identity,
resolve_sync_base_url,
)
from tools.skills_sync_client_wire import KIND_BLOB, KIND_COMMIT, KIND_TREE, wire_address
from .contract import ContentFile, SystemSpecification, parse_manifest_bytes
from .package import PackagePolicyError, verify_content_files
from .client_delivery import (
ClientDeliveryClaim,
ClientDeliveryResponse,
Identifier,
receipt_projection,
)
from .delivery import DeliveryReceipt
from .client_outcome import ClientOperationOutcome, ClientOperationResponse
logger = logging.getLogger(__name__)
class WisdomError(RuntimeError):
exit_code = 8
def __init__(
self, message: str, *, status: int | None = None, code: str | None = None
):
super().__init__(message)
self.status = status
self.code = code
class WisdomAuthError(WisdomError):
exit_code = 3
class WisdomNotFound(WisdomError):
exit_code = 4
class WisdomConflict(WisdomError):
exit_code = 5
class WisdomValidationError(WisdomError):
exit_code = 6
class WireModel(BaseModel):
model_config = ConfigDict(extra="allow")
class AgentLedNotificationDefaults(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
skill_ready_to_share: bool
teammate_published: bool
update_available: bool
class AgentLedPolicyResponse(BaseModel):
model_config = ConfigDict(strict=True, extra="ignore")
org_id: str = Field(min_length=1)
usage_evidence_window_days: int = Field(ge=1, le=90)
min_aggregate_invocations: int = Field(ge=1, le=10_000)
consecutive_day_usage_counts: bool
repeated_edits_count: bool
max_recommendations_per_user_per_week: int = Field(ge=0, le=100)
publication_mode: Literal["open", "moderated", "managed"]
install_popularity_threshold: int = Field(ge=1, le=100_000)
notification_defaults: AgentLedNotificationDefaults
manager_review_email_cadence: Literal["immediate", "daily"]
not_now_suppression_days: int = Field(ge=1, le=365)
version: int = Field(ge=1)
updated_by_user_id: str | None
class WisdomSuppression(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
key: str = Field(pattern=r"^sha256:[a-f0-9]{64}$")
suppress_until: str
class WisdomSuppressionResponse(WisdomSuppression):
org_id: str
class WisdomSuppressionList(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
org_id: str
suppressions: list[WisdomSuppression] = Field(max_length=100)
class WisdomMuteResponse(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
org_id: str
muted: bool
duration: Literal["1_day", "1_week", "30_days", "forever"] | None
muted_until: str | None
forever: bool
revision: int = Field(default=0, ge=0)
mutation_id: str | None = None
updated_at: str | None = None
class Draft(WireModel):
id: str
supersedesDraftId: str | None = None
orgId: str
ownerUserId: str
slug: str
draftCommit: str
contentHash: str
authorDescription: str | None
authorDescriptionHash: str | None
state: Literal[
"vetting",
"ready",
"owner_approved",
"publishing",
"pending_moderation",
"changes_requested",
"published",
"declined",
"invalidated",
]
moderationNote: str | None = None
moderationDeciderUserId: str | None = None
moderationDecidedAt: str | None = None
packageManifestHash: str | None
packageManifestSchemaVersion: int | None
systemSpec: SystemSpecification | None
scan: dict[str, Any] | None
scanVerdict: str | None
security_check: dict[str, Any] | None = None
professionalism_check: dict[str, Any] | None = None
explanation: str | None
updatedAt: str
@model_validator(mode="after")
def require_return_metadata(self) -> Draft:
if self.state == "changes_requested" and (
not self.moderationNote
or not self.moderationDeciderUserId
or not self.moderationDecidedAt
):
raise ValueError(
"changes_requested drafts require complete moderator return metadata"
)
return self
class DraftDetail(WireModel):
draft: Draft
effective_policy: dict[str, Any]
class DraftResponse(WireModel):
draft: Draft
class DraftList(WireModel):
drafts: list[Draft]
class SkillSummary(WireModel):
id: str
slug: str
state: str
created_by_user_id: str
latest_version: int | None
author_description: str | None
verified_facts: dict[str, Any] | None
explanation: str | None
install_count: int
takedown_generation: int
system_spec: SystemSpecification | None
security_check: dict[str, Any] | None = None
professionalism_check: dict[str, Any] | None = None
class SkillList(WireModel):
skills: list[SkillSummary]
next_cursor: str | None
class SkillDetail(WireModel):
skill: dict[str, Any]
versions: list[dict[str, Any]]
class VersionDetail(WireModel):
skill: dict[str, Any]
version: dict[str, Any]
class VersionContent(WireModel):
commit: str
content_hash: str
files: list[dict[str, Any]]
class InstallationRecord(WireModel):
installed_version: int = Field(gt=0)
effective_update_mode: Literal["MANUAL", "AUTO_WITH_NOTICE", "REQUIRED"]
class InstallationItem(WireModel):
skill_id: str
installed_version: int = Field(gt=0)
latest_version: int | None = Field(default=None, gt=0)
update_mode: Literal["MANUAL", "AUTO_WITH_NOTICE", "REQUIRED"]
skill_state: str
takedown_generation: int | None = Field(default=None, ge=0)
class InstallationList(WireModel):
installations: list[InstallationItem]
class InstallationDeactivation(WireModel):
skill_id: str
installation_id: str
state: Literal["inactive"]
class FeedEvent(WireModel):
event_id: str
kind: Literal[
"new",
"updated",
"archived",
"taken_down",
"restored",
"installed",
"installation_updated",
"uninstalled",
]
skill_id: str
version: int | None = Field(default=None, gt=0)
takedown_generation: int | None = Field(default=None, ge=0)
installation_id: str | None
update_mode: Literal["MANUAL", "AUTO_WITH_NOTICE", "REQUIRED"] | None
occurred_at: datetime
class Feed(WireModel):
events: list[FeedEvent]
next_cursor: str
has_more: bool
@dataclass(frozen=True)
class ReconstructedDraft:
detail: DraftDetail
files: list[tuple[str, str, bytes]]
content_files: list[ContentFile]
content_hash: str
def _walk_private_commit(
sync: SyncClient, commit_hash: str
) -> list[tuple[str, str, bytes]]:
kind, body = sync.get_object(commit_hash)
if wire_address(body) != commit_hash or kind != KIND_COMMIT:
raise WisdomValidationError(
"owner-private draft commit failed integrity validation"
)
try:
commit = json.loads(body.decode("utf-8"))
tree_hash = str(commit["tree"])
except (
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
json.JSONDecodeError,
) as exc:
raise WisdomValidationError("owner-private draft commit is malformed") from exc
out: list[tuple[str, str, bytes]] = []
seen_nodes: set[tuple[str, str]] = set()
def walk(tree: str, prefix: str) -> None:
node_key = (tree, prefix)
if node_key in seen_nodes:
raise WisdomValidationError("owner-private draft contains a tree cycle")
seen_nodes.add(node_key)
node_kind, node_body = sync.get_object(tree)
if node_kind != KIND_TREE or wire_address(node_body) != tree:
raise WisdomValidationError(
"owner-private draft tree failed integrity validation"
)
try:
parsed = json.loads(node_body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WisdomValidationError(
"owner-private draft tree is malformed"
) from exc
entries = parsed.get("entries")
if not isinstance(entries, list):
raise WisdomValidationError("owner-private draft tree has no entries")
for entry in entries:
if not isinstance(entry, dict):
raise WisdomValidationError(
"owner-private draft tree entry is malformed"
)
name = entry.get("name")
address = entry.get("hash")
entry_kind = entry.get("kind")
if not isinstance(name, str) or not isinstance(address, str):
raise WisdomValidationError(
"owner-private draft tree entry is malformed"
)
path = f"{prefix}{name}"
if entry_kind == KIND_TREE:
walk(address, path + "/")
elif entry_kind == KIND_BLOB:
blob_kind, blob = sync.get_object(address)
if blob_kind != KIND_BLOB or wire_address(blob) != address:
raise WisdomValidationError(
f"draft blob failed integrity validation: {path}"
)
out.append((
path,
"exec" if entry.get("mode") == "exec" else "file",
blob,
))
else:
raise WisdomValidationError(f"unsupported draft object kind at {path}")
walk(tree_hash, "")
return out
class WisdomClient:
def __init__(self, *, timeout: float = 30.0) -> None:
identity = resolve_identity()
base = resolve_sync_base_url()
if not base:
raise WisdomAuthError("Collective Wisdom Gateway is not configured")
self.identity = identity
self.base = base.rstrip("/")
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {identity['api_key']}",
"Accept": "application/json",
})
self.sync = SyncClient(self.base, identity["api_key"], timeout=timeout)
@property
def display_scopes(self) -> tuple[str, ...]:
scopes = self.identity.get("claims", {}).get("wisdom_scopes")
return (
tuple(item for item in scopes if isinstance(item, str))
if isinstance(scopes, list)
else ()
)
@property
def display_org_id(self) -> str | None:
claims = self.identity.get("claims", {})
value = claims.get("org_id") or claims.get("orgId")
return str(value) if value else None
def _url(self, path: str) -> str:
return f"{self.base}/v1/sync/wisdom/{path.lstrip('/')}"
def _request(
self,
method: str,
path: str,
*,
model: type[BaseModel] | None = None,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
try:
response = self.session.request(
method,
self._url(path),
json=json_body,
params=params,
timeout=self.timeout,
)
except requests.Timeout as exc:
raise WisdomError("Collective Wisdom Gateway timed out") from exc
except requests.RequestException as exc:
raise WisdomError("Collective Wisdom Gateway is unavailable") from exc
body: Any = None
if response.content:
try:
body = response.json()
except ValueError as exc:
raise WisdomError(
"Collective Wisdom Gateway returned a malformed response"
) from exc
code = body.get("error") if isinstance(body, dict) else None
if response.status_code in (401, 403):
raise WisdomAuthError(
"Collective Wisdom is unavailable for this account",
status=response.status_code,
code=code,
)
if response.status_code == 404:
raise WisdomNotFound(
"Collective Wisdom item not found", status=404, code=code
)
if response.status_code == 409:
raise WisdomConflict(
str(code or "Collective Wisdom state changed; refresh and retry"),
status=409,
code=code,
)
if response.status_code == 422:
raise WisdomValidationError(
str(code or "Collective Wisdom rejected invalid content"),
status=422,
code=code,
)
if response.status_code < 200 or response.status_code >= 300:
raise WisdomError(
f"Collective Wisdom Gateway failed ({response.status_code})",
status=response.status_code,
code=code,
)
if model is None:
return body
try:
return model.model_validate(body)
except ValidationError as exc:
raise WisdomError(
"Collective Wisdom Gateway response failed schema validation"
) from exc
def capability(self) -> dict[str, Any]:
try:
response = self.session.get(
f"{self.base}/v1/sync/capabilities", timeout=self.timeout
)
response.raise_for_status()
body = response.json()
except (requests.RequestException, ValueError) as exc:
raise WisdomError("Gateway capabilities are unavailable") from exc
if "wisdom" not in (body.get("features") or []):
raise WisdomAuthError("Gateway does not advertise Collective Wisdom")
return body
def agent_led_policy(self) -> AgentLedPolicyResponse:
policy = self._request("GET", "agent-led/policy", model=AgentLedPolicyResponse)
if not self.display_org_id or policy.org_id != self.display_org_id:
raise WisdomError(
"Recommendation policy does not match the active organization"
)
return policy
def _delivery_identity(self) -> tuple[str, str]:
org = self.display_org_id
user = self.identity.get("owner")
if not org or not isinstance(user, str) or not user or user == "unknown":
raise WisdomAuthError("Notification delivery requires an active account")
return org, user
def _delivery_response(self, response, identity, claim, *, event_id=None):
if (
self._delivery_identity() != identity
or (response.org_id, response.recipient_user_id) != identity
or response.request_id != claim.request_id
or response.reference != claim.reference
or (event_id is not None and response.event_id != event_id)
):
raise WisdomError("Notification delivery response does not match the request")
return response
def claim_notification_delivery(self, request_id: str, reference: dict) -> ClientDeliveryResponse:
"""Caller persists request_id first; a replay never extends the send lease."""
identity = self._delivery_identity()
try:
claim = ClientDeliveryClaim(request_id=request_id, reference=reference)
except ValidationError as exc:
raise WisdomValidationError("Invalid notification delivery reference") from exc
response = self._request(
"POST", "agent-led/deliveries/claim", model=ClientDeliveryResponse,
json_body=claim.model_dump(mode="json"),
)
return self._delivery_response(response, identity, claim)
def settle_notification_delivery(
self, event_id: str, request_id: str, reference: dict, *,
outcome: Literal["acknowledged", "not_sent", "uncertain"],
receipt: DeliveryReceipt | None = None,
) -> ClientDeliveryResponse:
"""Reconcile a known local result without resending or granting consent."""
identity = self._delivery_identity()
try:
claim = ClientDeliveryClaim(request_id=request_id, reference=reference)
TypeAdapter(Identifier).validate_python(event_id, strict=True)
if outcome not in {"acknowledged", "not_sent", "uncertain"}:
raise ValueError("Unknown settlement")
if (outcome == "acknowledged") != (receipt is not None):
raise ValueError("Only acknowledgements require receipts")
body = {"request_id": request_id, "outcome": outcome}
if receipt is not None:
body["receipt"] = receipt_projection(receipt).model_dump(mode="json")
except (ValidationError, ValueError) as exc:
raise WisdomValidationError("Invalid notification delivery settlement") from exc
response = self._request(
"POST", f"agent-led/deliveries/{quote(event_id, safe='')}/settle",
model=ClientDeliveryResponse, json_body=body,
)
self._delivery_response(response, identity, claim, event_id=event_id)
if response.state != outcome:
raise WisdomError("Notification delivery settlement was not acknowledged")
return response
def _preference_org(self, response):
if not self.display_org_id or response.org_id != self.display_org_id:
raise WisdomError("Preference does not match the active organization")
return response
def report_operation_outcome(self, event_id: str, outcome: dict) -> ClientOperationResponse:
"""Sync a persisted local journal result. Never apply, publish or grant consent."""
identity = self._delivery_identity()
try:
TypeAdapter(Identifier).validate_python(event_id, strict=True)
report = ClientOperationOutcome.model_validate(outcome)
except ValidationError as exc:
raise WisdomValidationError("Invalid operation outcome") from exc
response = self._request(
"POST", f"agent-led/deliveries/{quote(event_id, safe='')}/outcomes",
model=ClientOperationResponse, json_body=report.model_dump(mode="json"),
)
if (
self._delivery_identity() != identity
or (response.org_id, response.recipient_user_id) != identity
or response.event_id != event_id
or response.outcome != report
):
raise WisdomError("Operation outcome response does not match the request")
return response
def suppress_recommendation(self, key: str) -> WisdomSuppressionResponse:
if not isinstance(key, str) or not re.fullmatch(r"sha256:[a-f0-9]{64}", key):
raise WisdomValidationError("Invalid suppression key")
return self._preference_org(
self._request(
"PUT",
f"agent-led/suppressions/{quote(key, safe='')}",
model=WisdomSuppressionResponse,
json_body={},
)
)
def recommendation_suppressions(self, keys: list[str]) -> list[WisdomSuppression]:
if len(keys) > 100 or len(set(keys)) != len(keys):
raise WisdomValidationError("Expected up to 100 distinct suppression keys")
for key in keys:
if not isinstance(key, str) or not re.fullmatch(
r"sha256:[a-f0-9]{64}", key
):
raise WisdomValidationError("Invalid suppression key")
result = self._preference_org(
self._request(
"POST",
"agent-led/suppressions/check",
model=WisdomSuppressionList,
json_body={"keys": keys},
)
)
if any(row.key not in keys for row in result.suppressions) or len({
row.key for row in result.suppressions
}) != len(result.suppressions):
raise WisdomError("Gateway returned unexpected suppression keys")
return result.suppressions
def recommendation_mute(self) -> WisdomMuteResponse:
return self._preference_org(
self._request(
"GET",
"agent-led/mute",
model=WisdomMuteResponse,
params={"include_revision": "1"},
)
)
def set_recommendation_mute(
self,
duration: str | None,
*,
expected_revision: int | None = None,
mutation_id: str | None = None,
requested_at: str | None = None,
) -> WisdomMuteResponse:
if duration not in {None, "1_day", "1_week", "30_days", "forever"}:
raise WisdomValidationError("Unsupported mute duration")
guarded = any(
value is not None
for value in (expected_revision, mutation_id, requested_at)
)
if guarded and (
type(expected_revision) is not int
or not 0 <= expected_revision < 2_147_483_647
or not mutation_id
or not requested_at
):
raise WisdomValidationError(
"Queued mute requires revision, mutation ID and request time"
)
payload = {"duration": duration}
if guarded:
payload.update(
expected_revision=expected_revision,
mutation_id=mutation_id,
requested_at=requested_at,
)
return self._preference_org(
self._request(
"DELETE" if duration is None and not guarded else "PUT",
"agent-led/mute",
model=WisdomMuteResponse,
json_body=payload if duration is not None or guarded else None,
)
)
def register_identity(self, installation_id: str) -> dict[str, Any]:
return self._request(
"POST",
"installation-identities",
json_body={"installation_id": installation_id},
)
def upload_private_objects(self, objects: ObjectSet) -> None:
self.sync.put_objects(objects.objects)
def submit_draft(
self,
*,
slug: str,
commit: str,
content_hash: str,
description: str,
professionalism_review: dict[str, Any] | None = None,
) -> Draft:
payload: dict[str, Any] = {
"slug": slug,
"draft_commit": commit,
"content_hash": content_hash,
"author_description": description,
}
if professionalism_review is not None:
payload["professionalism_review"] = professionalism_review
body = self._request(
"POST",
"drafts",
model=DraftResponse,
json_body=payload,
)
return body.draft
def list_drafts(self) -> list[Draft]:
return self._request("GET", "drafts", model=DraftList).drafts
def draft(self, draft_id: str) -> DraftDetail:
return self._request(
"GET", f"drafts/{quote(draft_id, safe='')}", model=DraftDetail
)
def reconstruct_draft(self, draft_id: str) -> ReconstructedDraft:
detail = self.draft(draft_id)
files = _walk_private_commit(self.sync, detail.draft.draftCommit)
records, content_hash = verify_content_files(files)
if content_hash != detail.draft.contentHash:
raise WisdomValidationError(
"server draft content does not match its authoritative hash"
)
return ReconstructedDraft(
detail=detail, files=files, content_files=records, content_hash=content_hash
)
def revise_draft(
self,
draft_id: str,
*,
commit: str,
content_hash: str,
description: str,
expected_content_hash: str,
expected_description_hash: str,
expected_manifest_hash: str,
professionalism_review: dict[str, Any] | None = None,
) -> Draft:
"""Create a newly vetted successor for an immutable owner draft."""
payload: dict[str, Any] = {
"draft_commit": commit,
"content_hash": content_hash,
"author_description": description,
"expected_content_hash": expected_content_hash,
"expected_author_description_hash": expected_description_hash,
"expected_package_manifest_hash": expected_manifest_hash,
}
if professionalism_review is not None:
payload["professionalism_review"] = professionalism_review
body = self._request(
"POST",
f"drafts/{quote(draft_id, safe='')}/revise",
json_body=payload,
)
return Draft.model_validate(body.get("draft", body))
def approve(
self,
draft_id: str,
*,
content_hash: str,
description_hash: str,
manifest_hash: str,
) -> Draft:
body = self._request(
"POST",
f"drafts/{quote(draft_id, safe='')}/approve",
json_body={
"content_hash": content_hash,
"author_description_hash": description_hash,
"package_manifest_hash": manifest_hash,
},
)
return Draft.model_validate(body.get("draft", body))
def publish(self, draft_id: str, *, content_hash: str) -> dict[str, Any]:
return self._request(
"POST",
f"drafts/{quote(draft_id, safe='')}/publish",
json_body={"content_hash": content_hash, "base_commit": None},
)
def decline(self, draft_id: str) -> dict[str, Any]:
return self._request("POST", f"drafts/{quote(draft_id, safe='')}/decline")
def list_skills(self, *, cursor: str | None = None) -> SkillList:
return self._request(
"GET",
"skills",
model=SkillList,
params={"cursor": cursor} if cursor else None,
)
def skill(self, skill_id: str) -> SkillDetail:
return self._request(
"GET", f"skills/{quote(skill_id, safe='')}", model=SkillDetail
)
def version(self, skill_id: str, version: int) -> VersionDetail:
return self._request(
"GET",
f"skills/{quote(skill_id, safe='')}/versions/{version}",
model=VersionDetail,
)
def content(
self,
skill_id: str,
version: int,
*,
installation_id: str,
takedown_generation: int,
) -> tuple[VersionContent, list[tuple[str, str, bytes]]]:
response = self._request(
"GET",
f"skills/{quote(skill_id, safe='')}/versions/{version}/content",
model=VersionContent,
params={
"installation_id": installation_id,
"takedown_generation": takedown_generation,
},
)
return self._decode_version_content(response)
def raw_copy(
self, skill_id: str, version: int
) -> tuple[VersionContent, list[tuple[str, str, bytes]]]:
"""Read an immutable published package without creating install state."""
response = self._request(
"GET",
f"skills/{quote(skill_id, safe='')}/versions/{version}/raw",
model=VersionContent,
)
return self._decode_version_content(response)
@staticmethod
def _decode_version_content(
response: VersionContent,
) -> tuple[VersionContent, list[tuple[str, str, bytes]]]:
decoded: list[tuple[str, str, bytes]] = []
for item in response.files:
try:
raw = base64.b64decode(item["content_base64"], validate=True)
except (KeyError, TypeError, ValueError) as exc:
raise WisdomValidationError(
"version content contains malformed base64"
) from exc
if wire_address(raw) != item.get("hash"):
raise WisdomValidationError(
f"version blob failed integrity validation: {item.get('path', '?')}"
)
decoded.append((str(item["path"]), str(item["mode"]), raw))
records, derived = verify_content_files(decoded)
if derived != response.content_hash:
raise WisdomValidationError(
"version content hash failed integrity validation"
)
manifest_body = next(
(body for path, _, body in decoded if path == "skill.manifest.json"), None
)
if manifest_body is None:
raise WisdomValidationError("version content has no package manifest")
try:
parse_manifest_bytes(manifest_body)
except (UnicodeDecodeError, ValueError) as exc:
raise WisdomValidationError("version package manifest is invalid") from exc
return response, decoded
def record_install(
self,
*,
skill_id: str,
installation_id: str,
version: int,
takedown_generation: int,
update_mode: str | None,
) -> InstallationRecord:
payload: dict[str, Any] = {
"skill_id": skill_id,
"installation_id": installation_id,
"version": version,
"takedown_generation": takedown_generation,
}
if update_mode:
payload["update_mode"] = update_mode
return self._request(
"POST", "installations", model=InstallationRecord, json_body=payload
)
def installations(self, installation_id: str) -> list[dict[str, Any]]:
result = self._request(
"GET",
f"installations/{quote(installation_id, safe='')}",
model=InstallationList,
)
return [item.model_dump(mode="json") for item in result.installations]
def deactivate_install(
self, installation_id: str, skill_id: str
) -> InstallationDeactivation:
return self._request(
"DELETE",
f"installations/{quote(installation_id, safe='')}/skills/{quote(skill_id, safe='')}",
model=InstallationDeactivation,
)
def feed(
self, cursor: str | None = None, *, installation_id: str | None = None
) -> Feed:
params: dict[str, str] = {}
if cursor:
params["cursor"] = cursor
if installation_id:
params["installation_id"] = installation_id
return self._request("GET", "feed", model=Feed, params=params or None)
+103
View File
@@ -0,0 +1,103 @@
"""Private wire projection for reserving and acknowledging local notifications.
These are client-reported transport receipts, not read receipts or consent.
Candidate content and transport identifiers never leave the profile here.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from .delivery import DeliveryReceipt
OpaqueKey = Annotated[str, Field(pattern=r"^sha256:[a-f0-9]{64}$")]
Identifier = Annotated[str, Field(min_length=1, max_length=256, pattern=r"^[^\x00-\x1f\x7f-\x9f]+$")]
RequestId = Annotated[str, Field(pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")]
class StrictModel(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
class CandidateDeliveryReference(StrictModel):
kind: Literal["candidate"]
key: OpaqueKey
class SkillDeliveryReference(StrictModel):
kind: Literal["skill"]
skill_id: Identifier
version: int = Field(ge=1, le=2_147_483_647)
event_type: Literal["teammate_published", "update_available"]
DeliveryReference = Annotated[CandidateDeliveryReference | SkillDeliveryReference, Field(discriminator="kind")]
class ClientDeliveryClaim(StrictModel):
request_id: RequestId
reference: DeliveryReference
class ClientDeliveryReceipt(StrictModel):
platform: Literal["telegram", "slack", "local"]
acknowledgement: Literal["provider_accepted", "transport_accepted"]
message_key: OpaqueKey
destination_key: OpaqueKey
@model_validator(mode="after")
def valid_acknowledgement(self):
expected = "transport_accepted" if self.platform == "local" else "provider_accepted"
if self.acknowledgement != expected:
raise ValueError("Acknowledgement does not match transport")
return self
class ClientDeliveryResponse(StrictModel):
org_id: Identifier
recipient_user_id: Identifier
event_id: Identifier
request_id: RequestId
reference: DeliveryReference
state: Literal["claimed", "acknowledged", "not_sent", "uncertain", "deferred"]
lease_until: str | None
reason: Literal[
"not_found", "in_progress", "retry_exhausted", "policy_changed",
"recipient_muted", "notifications_disabled", "reference_invalid", "frequency_cap",
] | None
@model_validator(mode="after")
def valid_directive(self):
if self.state == "claimed":
if not self.lease_until or self.reason is not None:
raise ValueError("Claim requires a lease and no refusal")
expiry = datetime.fromisoformat(self.lease_until.replace("Z", "+00:00"))
if expiry.tzinfo is None or not self.lease_until.endswith("Z"):
raise ValueError("Lease must be UTC")
elif self.lease_until is not None:
raise ValueError("Non-claim cannot grant a lease")
if (self.state == "deferred") != (self.reason is not None):
raise ValueError("Only deferrals have a reason")
return self
def receipt_projection(receipt: DeliveryReceipt) -> ClientDeliveryReceipt:
if not isinstance(receipt, DeliveryReceipt):
raise ValueError("A validated local transport receipt is required")
def key(kind: str, parts: list[str]) -> str:
data = json.dumps(["wisdom-delivery-v1", kind, *parts], separators=(",", ":"))
return "sha256:" + hashlib.sha256(data.encode()).hexdigest()
destination = [receipt.platform, receipt.scope_id, receipt.destination, receipt.thread_id]
return ClientDeliveryReceipt(
platform=receipt.platform,
acknowledgement=receipt.acknowledgement,
destination_key=key("destination", destination),
message_key=key("message", [*destination, receipt.message_id]),
)
+33
View File
@@ -0,0 +1,33 @@
"""Private, metadata-only journal outcome reports; never authorization to act."""
from typing import Literal
from pydantic import model_validator
from .client_delivery import Identifier, OpaqueKey, RequestId, StrictModel
class ClientOperationOutcome(StrictModel):
request_id: RequestId
operation_key: OpaqueKey
operation: Literal["share", "publish", "install", "update"]
state: Literal["queued", "files_installed", "completed", "failed", "stale", "expired", "needs_review"]
@model_validator(mode="after")
def valid_result(self):
if self.state == "queued" and self.operation != "share":
raise ValueError("Only packaging may be queued")
if self.state == "completed" and self.operation == "share":
raise ValueError("Packaging queued is not publication completed")
if self.state == "files_installed" and self.operation not in {"install", "update"}:
raise ValueError("Only install and update can report installed files")
return self
class ClientOperationResponse(StrictModel):
org_id: Identifier
recipient_user_id: Identifier
event_id: Identifier
outcome: ClientOperationOutcome
attestation: Literal["client_reported"]
duplicate: bool

Some files were not shown because too many files have changed in this diff Show More