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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: 'الكل',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Настраивается:',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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__
|
||||
)
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Hermes Collective Wisdom local plane and Gateway client."""
|
||||
|
||||
from .contract import CONTRACT_PIN
|
||||
|
||||
__all__ = ["CONTRACT_PIN"]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()},
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]),
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user