Files
hermes-agent/hermes_wisdom/consumption.py
T
shannonsands a6ee31f55a 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>
2026-09-11 19:04:06 +10:00

1504 lines
63 KiB
Python

"""Crash-safe managed updates, feed reconciliation, and local notifications."""
from __future__ import annotations
import json
import logging
import os
import shutil
import tempfile
import uuid
from html import escape
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, Callable
from agent.skill_utils import extract_skill_editorial_metadata, parse_frontmatter
from hermes_constants import get_skills_dir
from .client import (
WisdomAuthError,
WisdomClient,
WisdomConflict,
WisdomNotFound,
WisdomValidationError,
)
from .compatibility import detect_local_capabilities, evaluate
from .contract import (
PackageManifest,
SystemSpecification,
org_directory_name,
parse_manifest_bytes,
sha256_address,
)
from .package import PackagePolicyError, verify_content_files
from .portal_links import portal_item_url
from .qualification import process_due_stability_jobs
from .store import WisdomStore
from .review_presentation import aggregate_review_text, full_review_text
UPDATE_MODES = {"MANUAL", "AUTO_WITH_NOTICE", "REQUIRED"}
MAX_NOTIFICATION_METADATA_ENRICHMENTS = 8
logger = logging.getLogger(__name__)
def _tree_hashes(root: Path) -> dict[str, str]:
if not root.is_dir() or root.is_symlink():
raise PackagePolicyError("managed install target is missing or unsafe")
tree: dict[str, str] = {}
for path in sorted(root.rglob("*")):
if path.is_symlink():
raise PackagePolicyError("managed install contains a symbolic link")
if path.is_file():
tree[path.relative_to(root).as_posix()] = sha256_address(path.read_bytes())
return tree
def _manifest(root: Path) -> PackageManifest:
try:
return parse_manifest_bytes((root / "skill.manifest.json").read_bytes())
except (OSError, ValueError) as exc:
raise PackagePolicyError(
"managed package manifest is missing or invalid"
) from exc
def _sensitive_expansion(
previous: SystemSpecification, incoming: SystemSpecification
) -> list[str]:
reasons: list[str] = []
def added(label: str, old: list[str], new: list[str]) -> None:
values = sorted(set(new) - set(old))
if values:
reasons.append(f"new {label}: {', '.join(values)}")
added("credentials", previous.credentials, incoming.credentials)
added("connections", previous.connections, incoming.connections)
added("filesystem reads", previous.filesystem.read, incoming.filesystem.read)
added("filesystem writes", previous.filesystem.write, incoming.filesystem.write)
added(
"network destinations",
previous.network.destinations,
incoming.network.destinations,
)
added("hardware requirements", previous.hardware, incoming.hardware)
old_tools = {item.name: item for item in previous.tools}
for tool in incoming.tools:
before = old_tools.get(tool.name)
if tool.requires_admin and (before is None or not before.requires_admin):
reasons.append(f"new privileged tool requirement: {tool.name}")
for field in ("shell", "browser", "code"):
if getattr(incoming.runtime, field) and not getattr(previous.runtime, field):
reasons.append(f"new {field} permission")
if previous.runtime.sandbox and not incoming.runtime.sandbox:
reasons.append("sandbox no longer required")
return reasons
def _public_notification_safe(event: dict[str, Any]) -> bool:
return event["category"] == "new_skill" or (
event["category"] == "publication_decision"
and event.get("state") in {"published", "approved"}
)
def _single_line(value: object, *, limit: int) -> str:
return " ".join(str(value or "").split())[:limit]
def _published_editorial_metadata(
files: list[tuple[str, str, bytes]],
*,
fallback_name: str,
fallback_description: str,
) -> dict[str, str]:
skill_body = next((body for path, _mode, body in files if path == "SKILL.md"), None)
if skill_body is None:
raise WisdomValidationError("published version has no SKILL.md")
try:
frontmatter, _body = parse_frontmatter(skill_body.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise WisdomValidationError("published SKILL.md metadata is invalid") from exc
resolved = extract_skill_editorial_metadata(
frontmatter,
fallback_name=fallback_name,
fallback_description=fallback_description,
)
return {
"editorial_name": _single_line(resolved["editorial_name"], limit=100),
"editorial_description": _single_line(
resolved["editorial_description"], limit=320
),
}
def _safe_target(store: WisdomStore, installation: dict[str, Any]) -> Path:
org_id = store.active_org_id()
if not org_id or installation["org_id"] != org_id:
raise PackagePolicyError("managed installation belongs to another organization")
managed_root = (get_skills_dir() / "_wisdom" / org_directory_name(org_id)).resolve()
target = Path(str(installation["target_path"])).resolve()
try:
target.relative_to(managed_root)
except ValueError as exc:
raise PackagePolicyError(
"managed target escaped the active Wisdom root"
) from exc
if target.name != installation["slug"]:
raise PackagePolicyError("managed target no longer matches its ledger identity")
return target
def _unique_fork_path(slug: str) -> Path:
root = get_skills_dir().resolve()
if Path(slug).name != slug:
raise PackagePolicyError("managed skill slug cannot escape the skills root")
for suffix in ["local-fork", *[f"local-fork-{index}" for index in range(2, 1000)]]:
candidate = (root / f"{slug}-{suffix}").resolve()
try:
candidate.relative_to(root)
except ValueError as exc:
raise PackagePolicyError(
"managed skill fork escaped the skills root"
) from exc
if not candidate.exists():
return candidate
raise PackagePolicyError("could not allocate a unique unmanaged fork name")
class WisdomConsumption:
def __init__(
self,
*,
store: WisdomStore,
client: WisdomClient,
scan: Callable[[Path], dict[str, Any]],
config: dict[str, Any],
) -> None:
self.store = store
self.client = client
self.scan = scan
self.config = config
def _portal_url(
self,
*,
skill_id: str | None = None,
version: int | None = None,
draft_id: str | None = None,
) -> str | None:
org_id = self.store.active_org_id()
if not org_id:
return None
base = str(
self.config.get("portal_url") or "https://portal.nousresearch.com"
).rstrip("/")
if draft_id:
return portal_item_url(base, org_id, "review", draft_id)
if not skill_id:
return None
return portal_item_url(base, org_id, "skills", skill_id, version=version)
def _notification_catalog(
self, events: list[dict[str, Any]]
) -> tuple[
dict[str, dict[str, Any]],
dict[str, dict[str, Any]],
dict[str, dict[str, str]],
]:
by_id: dict[str, dict[str, Any]] = {}
by_slug: dict[str, dict[str, Any]] = {}
for installation in self.store.installations():
item = dict(installation)
by_id[str(item["skill_id"])] = item
slug = item.get("slug")
if isinstance(slug, str) and slug:
by_slug[slug] = item
unresolved = {
str(event["skill_id"])
for event in events
if str(event["skill_id"]) not in by_id
or not {
"author_description",
"security_check",
"professionalism_check",
}.issubset(by_id[str(event["skill_id"])])
}
if unresolved:
cursor: str | None = None
try:
for _page in range(20):
response = self.client.list_skills(cursor=cursor)
for skill in response.skills:
item = skill.model_dump(mode="json")
by_id[skill.id] = item
by_slug[skill.slug] = item
cursor = response.next_cursor
if not cursor or not (unresolved - by_id.keys()):
break
except Exception:
# Notification rendering is an enhancement. A temporary discovery
# failure must not break update checks or expose opaque identifiers.
logger.debug("Wisdom notification enrichment failed", exc_info=True)
presentation: dict[str, dict[str, str]] = {}
raw_cache: dict[tuple[str, int], dict[str, str] | None] = {}
enrichment_count = 0
for event in events:
event_id = str(event["event_id"])
payload = event.get("payload")
payload = payload if isinstance(payload, dict) else {}
skill_id = str(event.get("skill_id") or "")
catalog_item = by_id.get(skill_id)
fallback_name = _single_line(
(catalog_item or {}).get("slug") or payload.get("slug"), limit=100
)
fallback_description = _single_line(
(catalog_item or {}).get("author_description"), limit=320
)
metadata = {
"editorial_name": _single_line(
payload.get("editorial_name") or fallback_name, limit=100
),
"editorial_description": _single_line(
payload.get("editorial_description") or fallback_description,
limit=320,
),
}
version = event.get("version") or payload.get("version")
raw_key = (
skill_id,
int(version) if isinstance(version, int) and version > 0 else 0,
)
should_enrich = (
str(event.get("kind") or "") in {"new", "updated"}
and raw_key[0]
and raw_key[1] > 0
and not {
"editorial_name",
"editorial_description",
}.issubset(payload)
)
if should_enrich and raw_key not in raw_cache:
if enrichment_count < MAX_NOTIFICATION_METADATA_ENRICHMENTS:
enrichment_count += 1
try:
_response, files = self.client.raw_copy(*raw_key)
raw_cache[raw_key] = _published_editorial_metadata(
files,
fallback_name=fallback_name,
fallback_description=fallback_description,
)
except Exception:
raw_cache[raw_key] = None
logger.debug(
"Wisdom notification editorial enrichment failed",
exc_info=True,
)
else:
raw_cache[raw_key] = None
enriched = raw_cache.get(raw_key)
if enriched:
metadata = enriched
self.store.enrich_feed_event(event_id, enriched)
payload.update(enriched)
presentation[event_id] = metadata
return by_id, by_slug, presentation
def _notification_projection(
self, events: list[dict[str, Any]]
) -> tuple[list[dict[str, Any]], list[str]]:
by_id, by_slug, presentation = self._notification_catalog(events)
installed_ids = {
str(item["skill_id"])
for item in self.store.installations()
if str(item.get("state") or "active") == "active"
}
local_installation_id = self.store.existing_installation_identity()
installation_updates = {
(str(item["skill_id"]), item.get("version"))
for item in events
if str(item.get("kind")) == "installation_updated"
and local_installation_id
and str((item.get("payload") or {}).get("installation_id") or "")
== local_installation_id
}
grouped: dict[tuple[str, str, int | None, str], dict[str, Any]] = {}
ignored: list[str] = []
for event in events:
event_id = str(event["event_id"])
payload = event.get("payload")
payload = payload if isinstance(payload, dict) else {}
kind = str(event.get("kind") or "")
skill_id = str(event.get("skill_id") or payload.get("skill_id") or "")
state = str(payload.get("state") or "")
version_value = event.get("version") or payload.get("version")
version = (
int(version_value)
if isinstance(version_value, int) and version_value > 0
else None
)
category: str | None = None
if kind == "owner_decision":
category = "publication_decision"
elif kind == "new":
category = "new_skill"
elif (
kind == "installed"
and local_installation_id
and str(payload.get("installation_id") or "") == local_installation_id
):
category = "installed"
elif (
kind == "installation_updated"
and local_installation_id
and str(payload.get("installation_id") or "") == local_installation_id
):
category = "updated"
elif kind == "updated":
if skill_id in installed_ids:
category = (
"updated"
if (skill_id, version) in installation_updates
else "update_available"
)
else:
# Org-wide revisions remain discoverable even when this
# device has not installed the skill. Reuse the installable
# new-skill category while preserving kind="updated" so
# every surface can render update-specific copy.
category = "new_skill"
elif kind in {"archived", "taken_down"} and skill_id in installed_ids:
category = "unavailable"
if category is None:
ignored.append(event_id)
continue
slug = str(payload.get("slug") or "")
catalog_item = by_id.get(skill_id)
if not catalog_item and slug:
catalog_item = by_slug.get(slug)
if catalog_item:
authoritative_id = str(catalog_item.get("id") or skill_id)
slug = str(catalog_item.get("slug") or slug)
if version is None:
latest = catalog_item.get("latest_version")
version = latest if isinstance(latest, int) and latest > 0 else None
skill_id = authoritative_id
elif not slug:
ignored.append(event_id)
continue
draft_id = str(payload.get("draft_id") or "") or None
use_draft_link = category == "publication_decision" and (
state != "published" or catalog_item is None
)
portal_url = self._portal_url(
skill_id=skill_id,
version=version,
draft_id=draft_id if use_draft_link else None,
)
key = (category, skill_id, version, state)
existing = grouped.get(key)
if existing:
existing["source_event_ids"].append(event_id)
continue
grouped[key] = {
"event_id": event_id,
"source_event_ids": [event_id],
"category": category,
"kind": kind,
"skill_id": skill_id,
"skill_name": slug,
"editorial_name": presentation.get(event_id, {}).get(
"editorial_name"
)
or slug,
"editorial_description": presentation.get(event_id, {}).get(
"editorial_description"
)
or None,
"version": version,
"state": state or None,
"moderation_note": payload.get("moderation_note"),
**({"draft_id": draft_id} if draft_id else {}),
"portal_url": portal_url,
"occurred_at": event.get("created_at") or payload.get("occurred_at"),
"security_check": (
catalog_item.get("security_check") if catalog_item else None
),
"professionalism_check": (
catalog_item.get("professionalism_check") if catalog_item else None
),
}
return list(grouped.values()), ignored
@staticmethod
def _telegram_notification_text(event: dict[str, Any]) -> tuple[str, str]:
name = str(event.get("editorial_name") or event["skill_name"])
version = f" · v{event['version']}" if event.get("version") else ""
description = _single_line(event.get("editorial_description"), limit=320)
detail = f"{name}{version}"
if description:
detail = f"{detail}\n{description}"
category = str(event["category"])
state = str(event.get("state") or "")
if category == "publication_decision":
if state in {"published", "approved"}:
return "✅ Your skill was published", detail
if state == "changes_requested":
return "✏️ Your skill needs changes", detail
if state in {"declined", "rejected"}:
return "↩️ Your skill was not published", detail
return "📣 Your contribution changed", detail
if category == "new_skill" and event.get("kind") == "updated":
return "🔄 Skill updated by your team", detail
if category == "new_skill":
return "🆕 New skill from your team", detail
if category == "installed":
return "⬇️ Installed on this device", detail
if category == "updated":
return "✅ Updated on this device", detail
if category == "update_available":
return "⬆️ Update available", detail
return "⚠️ Installed skill is unavailable", detail
def _remote_installations(self) -> dict[str, dict[str, Any]]:
identity = self.store.installation_identity()
return {
str(item["skill_id"]): item for item in self.client.installations(identity)
}
def _content(
self, skill_id: str, version: int, takedown_generation: int
) -> tuple[Any, list[tuple[str, str, bytes]]]:
installation_id = self.store.existing_installation_identity()
if not installation_id:
raise PackagePolicyError(
"run `hermes wisdom setup` before downloading managed content"
)
return self.client.content(
skill_id,
version,
installation_id=installation_id,
takedown_generation=takedown_generation,
)
def check(self, *, apply_automatic: bool = False) -> dict[str, Any]:
qualification_events = process_due_stability_jobs(store=self.store)
feed = self.poll_feed()
decisions = self.poll_owner_decisions()
remote = self._remote_installations()
results: list[dict[str, Any]] = []
for local in self.store.installations():
if local["state"] != "active":
continue
authoritative = remote.get(str(local["skill_id"]))
if not authoritative:
results.append({"skill_id": local["skill_id"], "state": "not_recorded"})
continue
skill_state = str(authoritative.get("skill_state") or "active")
if skill_state == "taken_down":
results.append({
"skill_id": local["skill_id"],
"state": "taken_down",
"local_installation_preserved": True,
})
continue
if skill_state == "archived":
results.append({"skill_id": local["skill_id"], "state": "archived"})
continue
latest = authoritative.get("latest_version")
if not isinstance(latest, int) or latest <= int(local["version"]):
results.append({"skill_id": local["skill_id"], "state": "current"})
continue
plan = self.update_plan(str(local["skill_id"]), remote=authoritative)
mode = str(authoritative.get("update_mode") or local["update_mode"])
should_apply = apply_automatic and mode in {"AUTO_WITH_NOTICE", "REQUIRED"}
if should_apply and plan["auto_allowed"]:
result = self.update_apply(plan["receipt"], automatic=True)
results.append({
"skill_id": local["skill_id"],
"state": "updated",
"result": result,
})
else:
results.append({
"skill_id": local["skill_id"],
"state": "update_available",
"plan": plan,
})
telegram = self.dispatch_telegram()
slack = self.dispatch_slack()
return {
"installations": results,
"qualification_events": qualification_events,
"feed": feed,
"owner_decisions": decisions,
"telegram": telegram,
"slack": slack,
}
def update_plan(
self, skill_id: str, *, remote: dict[str, Any] | None = None
) -> dict[str, Any]:
lock = self.store.acquire_operation_lock(skill_id)
if not lock:
raise PackagePolicyError(
"another managed operation is already active for this skill"
)
try:
return self._update_plan_unlocked(skill_id, remote=remote)
finally:
self.store.release_operation_lock(skill_id, lock)
def _update_plan_unlocked(
self, skill_id: str, *, remote: dict[str, Any] | None = None
) -> dict[str, Any]:
installation = self.store.installation(skill_id)
if not installation or installation["state"] != "active":
raise PackagePolicyError("managed installation is not active")
target = _safe_target(self.store, installation)
authoritative = remote or self._remote_installations().get(skill_id)
if not authoritative:
raise PackagePolicyError(
"Gateway has no active record for this installation"
)
if authoritative.get("skill_state") != "active":
raise PackagePolicyError("inactive Wisdom skills cannot be updated")
version = authoritative.get("latest_version")
if not isinstance(version, int) or version <= int(installation["version"]):
return {
"skill_id": skill_id,
"state": "current",
"installed_version": installation["version"],
}
try:
generation = int(authoritative["takedown_generation"])
except (KeyError, TypeError, ValueError) as exc:
raise PackagePolicyError(
"Gateway returned incomplete update authorization"
) from exc
response, files = self._content(skill_id, version, generation)
records, content_hash = verify_content_files(files)
if content_hash != response.content_hash:
raise WisdomValidationError("update content failed integrity validation")
baseline = {record.path: record.hash for record in records}
staging_parent = self.store.root / "update-plans"
staging_parent.mkdir(parents=True, exist_ok=True, mode=0o700)
reusable = self._reusable_update_plan(
staging_parent,
skill_id=skill_id,
from_version=int(installation["version"]),
version=version,
content_hash=content_hash,
baseline=baseline,
)
if reusable:
receipt, staging, plan_path = reusable
else:
receipt = "wup_" + uuid.uuid4().hex
plan_path = staging_parent / f"{receipt}.json"
staging = Path(tempfile.mkdtemp(prefix=f".{receipt}-", dir=staging_parent))
for raw_path, _mode, body in files:
destination = staging.joinpath(*PurePosixPath(raw_path).parts)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(body)
destination.chmod(0o600)
scan = self.scan(staging)
if scan["guard"]["allowed"] is False:
shutil.rmtree(staging)
plan_path.unlink(missing_ok=True)
raise PackagePolicyError(
f"built-in guard blocked update: {scan['guard']['reason']}"
)
incoming = _manifest(staging)
compatibility = evaluate(
incoming.requirements,
detect_local_capabilities(incoming.requirements),
)
try:
previous = _manifest(target)
sensitive = _sensitive_expansion(
previous.requirements, incoming.requirements
)
except PackagePolicyError:
sensitive = [
"existing managed manifest is invalid; requirement expansion cannot be proven safe"
]
if any(
item.get("secrets_class") and item.get("severity") in {"critical", "high"}
for item in scan.get("skill_evaluator", {}).get("findings", [])
):
sensitive.append("high-confidence local secret finding")
modified = _tree_hashes(target) != installation["baseline"]
mode = str(authoritative.get("update_mode") or installation["update_mode"])
if mode not in UPDATE_MODES:
shutil.rmtree(staging)
raise PackagePolicyError("Gateway returned an unsupported update mode")
plan = {
"receipt": receipt,
"kind": "update",
"skill_id": skill_id,
"slug": installation["slug"],
"from_version": int(installation["version"]),
"version": version,
"content_hash": content_hash,
"baseline": baseline,
"previous_baseline": installation["baseline"],
"previous_installation": installation,
"target_path": str(target),
"staging_path": str(staging),
"takedown_generation": generation,
"update_mode": mode,
"modified": modified,
"sensitive_expansion": sensitive,
"compatibility": asdict(compatibility),
"local_scan": scan,
"auto_allowed": (
not sensitive
and (not modified or mode == "REQUIRED")
and compatibility.outcome == "compatible"
and scan["guard"]["allowed"] is True
and not scan["guard"].get("findings")
and not scan.get("skill_evaluator", {}).get("findings")
),
}
plan_path.write_text(json.dumps(plan, sort_keys=True), encoding="utf-8")
plan_path.chmod(0o600)
return plan
def _reusable_update_plan(
self,
root: Path,
*,
skill_id: str,
from_version: int,
version: int,
content_hash: str,
baseline: dict[str, str],
) -> tuple[str, Path, Path] | None:
"""Reuse one exact, unapplied plan and remove superseded idle plans."""
root = root.resolve()
pending_receipts: set[str] = set()
for operation in self.store.pending_operations():
if operation["kind"] != "update":
continue
try:
payload = json.loads(operation["payload_json"])
except (TypeError, json.JSONDecodeError):
continue
receipt = payload.get("receipt")
if isinstance(receipt, str):
pending_receipts.add(receipt)
reusable: tuple[str, Path, Path] | None = None
for plan_path in root.glob("wup_*.json"):
try:
plan = json.loads(plan_path.read_text(encoding="utf-8"))
receipt = str(plan["receipt"])
if plan.get("skill_id") != skill_id:
continue
staging = Path(str(plan["staging_path"])).resolve()
staging.relative_to(root)
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
continue
try:
exact = (
receipt not in pending_receipts
and int(plan.get("from_version", 0)) == from_version
and int(plan.get("version", 0)) == version
and plan.get("content_hash") == content_hash
and plan.get("baseline") == baseline
)
except (TypeError, ValueError):
exact = False
try:
exact = exact and _tree_hashes(staging) == baseline
except PackagePolicyError:
exact = False
if exact and reusable is None:
reusable = (receipt, staging, plan_path)
continue
if receipt not in pending_receipts:
if staging.is_dir() and not staging.is_symlink():
shutil.rmtree(staging)
plan_path.unlink(missing_ok=True)
return reusable
def update_apply(
self,
receipt: str,
*,
accept_sensitive: bool = False,
accept_partial: bool = False,
preserve_modified: bool = False,
automatic: bool = False,
) -> dict[str, Any]:
plan_path = self.store.root / "update-plans" / f"{receipt}.json"
try:
plan = json.loads(plan_path.read_text(encoding="utf-8"))
skill_id = str(plan["skill_id"])
except (OSError, KeyError, json.JSONDecodeError) as exc:
raise PackagePolicyError(
"update plan receipt is missing or invalid"
) from exc
lock = self.store.acquire_operation_lock(skill_id)
if not lock:
raise PackagePolicyError(
"another managed operation is already active for this skill"
)
try:
return self._update_apply_unlocked(
receipt,
accept_sensitive=accept_sensitive,
accept_partial=accept_partial,
preserve_modified=preserve_modified,
automatic=automatic,
)
finally:
self.store.release_operation_lock(skill_id, lock)
def _update_apply_unlocked(
self,
receipt: str,
*,
accept_sensitive: bool = False,
accept_partial: bool = False,
preserve_modified: bool = False,
automatic: bool = False,
) -> dict[str, Any]:
plan_path = self.store.root / "update-plans" / f"{receipt}.json"
try:
plan = json.loads(plan_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PackagePolicyError(
"update plan receipt is missing or invalid"
) from exc
pending = next(
(
item
for item in self.store.pending_operations()
if item["kind"] == "update"
and item["entity_id"] == str(plan.get("skill_id"))
),
None,
)
if pending:
persisted = json.loads(pending["payload_json"])
return self._resume_update(
str(pending["id"]),
persisted,
preserve=bool(persisted.get("preserve_required")),
)
installation = self.store.installation(str(plan["skill_id"]))
if not installation or installation["state"] != "active":
raise PackagePolicyError("managed installation is not active")
target = _safe_target(self.store, installation)
if int(installation["version"]) != int(plan["from_version"]):
raise PackagePolicyError("update plan is stale; create a new plan")
authoritative = self._remote_installations().get(str(plan["skill_id"]))
if not authoritative or authoritative.get("skill_state") != "active":
raise PackagePolicyError(
"Gateway no longer authorizes this managed update; create a new plan"
)
try:
remote_generation = int(authoritative["takedown_generation"])
remote_installed = int(authoritative["installed_version"])
remote_latest = int(authoritative["latest_version"])
except (KeyError, TypeError, ValueError) as exc:
raise PackagePolicyError(
"Gateway returned incomplete installation authority"
) from exc
remote_mode = str(authoritative.get("update_mode") or "")
if (
remote_generation != int(plan["takedown_generation"])
or remote_installed != int(plan["from_version"])
or remote_latest != int(plan["version"])
or remote_mode != str(plan["update_mode"])
):
raise PackagePolicyError(
"update authority or policy changed after planning; create a new plan"
)
response, remote_files = self._content(
str(plan["skill_id"]),
int(plan["version"]),
remote_generation,
)
records, remote_hash = verify_content_files(remote_files)
remote_baseline = {record.path: record.hash for record in records}
if (
remote_hash != response.content_hash
or remote_hash != str(plan["content_hash"])
or remote_baseline != plan["baseline"]
):
raise WisdomValidationError(
"authoritative update bytes changed after planning"
)
staging = Path(str(plan["staging_path"]))
if _tree_hashes(staging) != plan["baseline"]:
raise WisdomValidationError("staged update bytes changed after planning")
current_scan = self.scan(staging)
if current_scan["guard"]["allowed"] is False:
raise PackagePolicyError(
f"built-in guard blocked update: {current_scan['guard']['reason']}"
)
incoming = _manifest(staging)
compatibility = evaluate(
incoming.requirements,
detect_local_capabilities(incoming.requirements),
)
try:
previous = _manifest(target)
sensitive = _sensitive_expansion(
previous.requirements, incoming.requirements
)
except PackagePolicyError:
sensitive = [
"existing managed manifest is invalid; requirement expansion cannot be proven safe"
]
if any(
item.get("secrets_class") and item.get("severity") in {"critical", "high"}
for item in current_scan.get("skill_evaluator", {}).get("findings", [])
):
sensitive.append("high-confidence local secret finding")
plan["local_scan"] = current_scan
plan["compatibility"] = asdict(compatibility)
plan["sensitive_expansion"] = sensitive
modified_now = _tree_hashes(target) != installation["baseline"]
plan["modified"] = modified_now
plan["auto_allowed"] = (
not sensitive
and (not modified_now or remote_mode == "REQUIRED")
and compatibility.outcome == "compatible"
and current_scan["guard"]["allowed"] is True
and not current_scan["guard"].get("findings")
and not current_scan.get("skill_evaluator", {}).get("findings")
)
if sensitive and not accept_sensitive:
raise PackagePolicyError(
"update adds sensitive requirements; explicit confirmation is required"
)
outcome = compatibility.outcome
if outcome == "blocked_pending_action":
raise PackagePolicyError(
"blocked compatibility requirements prevent activation"
)
if outcome in {"partial", "compatible_after_setup"} and not accept_partial:
raise PackagePolicyError(
"compatibility action is required; explicitly accept the plan"
)
mode = str(plan["update_mode"])
if modified_now and mode != "REQUIRED" and not preserve_modified:
raise PackagePolicyError(
"This skill has local edits. Update stopped to protect your changes. "
"Run again with --preserve-modified to save your edited copy as a "
"separate local skill and install the team's update."
)
if automatic and (not plan["auto_allowed"] or mode not in {"AUTO_WITH_NOTICE", "REQUIRED"}):
raise PackagePolicyError(
"automatic update is not safe without explicit action"
)
preserve = modified_now and (mode == "REQUIRED" or preserve_modified)
plan["automatic"] = automatic
plan["preserve_required"] = preserve
if preserve and not plan.get("fork_path"):
plan["fork_path"] = str(_unique_fork_path(str(plan["slug"])))
operation = self.store.journal("update", str(plan["skill_id"]), "planned", plan)
operation_row = self.store.operation(operation)
if operation_row:
plan = operation_row["payload"]
return self._resume_update(
operation,
plan,
preserve=preserve,
)
def _resume_update(
self, operation_id: str, plan: dict[str, Any], *, preserve: bool
) -> dict[str, Any]:
operation = self.store.operation(operation_id)
if not operation:
raise PackagePolicyError("update recovery journal is missing")
phase = str(operation["phase"])
target = Path(str(plan["target_path"]))
staging = Path(str(plan["staging_path"]))
recovery = self.store.root / "recovery" / operation_id
recovery.mkdir(parents=True, exist_ok=True, mode=0o700)
if phase == "planned" and preserve:
exact = recovery / "managed-original"
if not exact.exists():
os.replace(target, exact)
fork = Path(str(plan["fork_path"]))
if fork.exists():
if _tree_hashes(fork) != _tree_hashes(exact):
raise WisdomValidationError(
"preserved unmanaged fork changed during update recovery"
)
else:
pending_fork = fork.with_name(f".{fork.name}.{operation_id}.pending")
if pending_fork.exists():
shutil.rmtree(pending_fork)
shutil.copytree(exact, pending_fork, copy_function=shutil.copy2)
os.replace(pending_fork, fork)
plan["recovery_path"] = str(exact)
self.store.replace_operation_payload(operation_id, plan)
self.store.advance(operation_id, "fork_preserved")
phase = "fork_preserved"
if phase == "planned":
self.store.advance(operation_id, "fork_preserved")
phase = "fork_preserved"
if phase == "fork_preserved":
expected = dict(plan["baseline"])
already_swapped = not staging.is_dir()
if staging.is_dir():
if _tree_hashes(staging) != expected:
raise WisdomValidationError(
"staged update bytes changed after planning"
)
elif not target.is_dir() or _tree_hashes(target) != expected:
raise PackagePolicyError(
"staged update bytes are unavailable for recovery"
)
backup = recovery / "replaced-managed"
if (
not preserve
and not already_swapped
and target.exists()
and _tree_hashes(target) != dict(plan["previous_baseline"])
):
raise PackagePolicyError(
"managed files changed during update planning; create a fresh plan"
)
if (
not preserve
and not already_swapped
and not target.exists()
and not backup.exists()
):
raise PackagePolicyError(
"managed update source disappeared before the atomic swap"
)
if not already_swapped and target.exists() and not backup.exists():
os.replace(target, backup)
if not already_swapped and not target.exists():
os.replace(staging, target)
self.store.advance(operation_id, "files_committed")
phase = "files_committed"
if phase == "files_committed":
installation = self.store.installation(str(plan["skill_id"]))
if not installation:
raise PackagePolicyError(
"managed installation ledger entry disappeared"
)
self.store.record_install({
"skill_id": plan["skill_id"],
"org_id": installation["org_id"],
"slug": plan["slug"],
"version": plan["version"],
"content_hash": plan["content_hash"],
"baseline": plan["baseline"],
"target_path": str(target),
"update_mode": plan["update_mode"],
})
self.store.advance(operation_id, "local_ledger_committed")
phase = "local_ledger_committed"
if phase == "local_ledger_committed":
try:
server = self.client.record_install(
skill_id=str(plan["skill_id"]),
installation_id=self.store.installation_identity(),
version=int(plan["version"]),
takedown_generation=int(plan["takedown_generation"]),
update_mode=str(plan["update_mode"]),
)
except (WisdomConflict, WisdomNotFound, WisdomValidationError) as exc:
rejected = recovery / "gateway-rejected"
if target.exists() and not rejected.exists():
os.replace(target, rejected)
terminal_codes = {
"skill_archived",
"skill_taken_down",
"takedown_generation_changed",
"skill_not_found",
"version_not_found",
}
if isinstance(exc, WisdomNotFound) or exc.code in terminal_codes:
self.store.deactivate_install(str(plan["skill_id"]))
self.store.advance(operation_id, "gateway_rejected", done=True)
raise
previous_installation = plan.get("previous_installation")
previous_tree = (
recovery / "managed-original"
if preserve
else recovery / "replaced-managed"
)
if not isinstance(previous_installation, dict):
raise PackagePolicyError(
"update recovery is missing the previous installation ledger"
) from exc
if not previous_tree.is_dir() or _tree_hashes(previous_tree) != dict(
previous_installation["baseline"]
):
raise PackagePolicyError(
"previous managed bytes are unavailable for rollback"
) from exc
os.replace(previous_tree, target)
self.store.record_install(previous_installation)
self.store.advance(
operation_id, "gateway_rejected_rolled_back", done=True
)
plan_path = self.store.root / "update-plans" / f"{plan['receipt']}.json"
plan_path.unlink(missing_ok=True)
raise
current = self.store.installation(str(plan["skill_id"]))
if current:
self.store.record_install({
**current,
"baseline": current["baseline"],
"update_mode": server.effective_update_mode,
})
if plan.get("automatic") is True:
from .setup_handoff import finish_automatic_update
finish_automatic_update(self.store, operation_id)
else:
self.store.advance(operation_id, "gateway_recorded", done=True)
plan_path = self.store.root / "update-plans" / f"{plan['receipt']}.json"
plan_path.unlink(missing_ok=True)
return {
"updated": True,
"skill_id": plan["skill_id"],
"version": plan["version"],
"path": str(target),
"preserved_fork": plan.get("fork_path"),
"recovery_path": plan.get("recovery_path"),
"effective_update_mode": server.effective_update_mode,
}
raise PackagePolicyError(f"unsupported update recovery phase: {phase}")
def uninstall(self, skill_id: str) -> dict[str, Any]:
lock = self.store.acquire_operation_lock(skill_id)
if not lock:
raise PackagePolicyError(
"another managed operation is already active for this skill"
)
try:
return self._uninstall_unlocked(skill_id)
finally:
self.store.release_operation_lock(skill_id, lock)
def _uninstall_unlocked(self, skill_id: str) -> dict[str, Any]:
installation = self.store.installation(skill_id)
if not installation:
raise PackagePolicyError("managed installation not found")
target = _safe_target(self.store, installation)
payload = {
"skill_id": skill_id,
"slug": installation["slug"],
"target_path": str(target),
}
operation = self.store.journal("uninstall", skill_id, "validated", payload)
return self._resume_uninstall(operation, payload)
def _resume_uninstall(
self, operation_id: str, plan: dict[str, Any]
) -> dict[str, Any]:
operation = self.store.operation(operation_id)
if not operation:
raise PackagePolicyError("uninstall recovery journal is missing")
phase = str(operation["phase"])
target = Path(str(plan["target_path"]))
slug = str(plan["slug"])
if Path(operation_id).name != operation_id or Path(slug).name != slug:
raise PackagePolicyError("uninstall recovery path is invalid")
trash_root = (self.store.root / "trash").resolve()
trash = (trash_root / operation_id / slug).resolve()
try:
trash.relative_to(trash_root)
except ValueError as exc:
raise PackagePolicyError(
"uninstall recovery path escaped the Wisdom trash root"
) from exc
if phase == "validated":
trash.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
if target.exists() and not trash.exists():
os.replace(target, trash)
self.store.advance(operation_id, "files_removed")
phase = "files_removed"
if phase == "files_removed":
self.store.deactivate_install(str(plan["skill_id"]))
self.store.advance(operation_id, "local_ledger_committed")
phase = "local_ledger_committed"
if phase == "local_ledger_committed":
result = self.client.deactivate_install(
self.store.installation_identity(), str(plan["skill_id"])
)
self.store.advance(operation_id, "gateway_deactivated", done=True)
return {
"uninstalled": True,
"skill_id": plan["skill_id"],
"state": result.state,
"recoverable_path": str(trash),
}
raise PackagePolicyError(f"unsupported uninstall recovery phase: {phase}")
def recover(self) -> list[str]:
recovered: list[str] = []
for operation in self.store.pending_operations():
entity_id = str(operation["entity_id"])
lock = self.store.acquire_operation_lock(entity_id)
if not lock:
continue
try:
payload = json.loads(operation["payload_json"])
if operation["kind"] == "update":
self._resume_update(
operation["id"],
payload,
preserve=bool(payload.get("preserve_required")),
)
elif operation["kind"] == "uninstall":
self._resume_uninstall(operation["id"], payload)
else:
continue
recovered.append(str(operation["entity_id"]))
except Exception:
continue
finally:
self.store.release_operation_lock(entity_id, lock)
return recovered
def poll_feed(self) -> dict[str, Any]:
cursor, generation = self.store.feed_position()
if not self.store.active_org_id():
raise WisdomAuthError(
"Wisdom account is signed out; sign in and re-verify your team"
)
inserted = 0
pages = 0
installation_id = self.store.installation_identity()
installed = {str(item["skill_id"]) for item in self.store.installations()}
while True:
page = self.client.feed(cursor, installation_id=installation_id)
events = [item.model_dump(mode="json") for item in page.events]
notification = self.config.get("notifications") or {}
cadences: dict[str, str] = {}
for event in events:
kind = str(event["kind"])
if kind == "new":
cadence = str(notification.get("new_skills", "daily"))
elif kind == "updated" and str(event["skill_id"]) not in installed:
cadence = str(notification.get("new_skills", "daily"))
elif kind in {"updated", "installation_updated"}:
cadence = str(notification.get("installed_updates", "immediate"))
else:
cadence = str(notification.get("decisions", "immediate"))
cadences[str(event["event_id"])] = (
cadence
if cadence in {"immediate", "daily", "weekly", "off"}
else "immediate"
)
now = datetime.now(timezone.utc).isoformat()
inserted += self.store.persist_feed_page(
events,
next_cursor=page.next_cursor,
cadences=cadences,
now=now,
expected_generation=generation,
)
pages += 1
cursor = page.next_cursor
if not page.has_more:
break
if pages >= 100:
raise WisdomValidationError(
"Wisdom feed exceeded the bounded page limit"
)
return {"inserted": inserted, "pages": pages, "cursor": cursor}
def poll_owner_decisions(self) -> dict[str, Any]:
inserted = 0
for draft in self.client.list_drafts():
local = self.store.draft(draft.id)
if not local or str(local["state"]) == draft.state:
continue
previous = str(local["state"])
if draft.state == "declined":
self.store.set_draft_state(draft.id, draft.state)
self.store.dismiss_candidate(
str(local["skill_id"]), str(local["source_hash"])
)
elif draft.state in {
"owner_approved",
"publishing",
"pending_moderation",
"changes_requested",
"published",
"invalidated",
}:
self.store.complete_contribution(draft.id, draft.state)
else:
self.store.set_draft_state(draft.id, draft.state)
if draft.state not in {
"approved",
"rejected",
"declined",
"published",
"changes_requested",
}:
continue
if draft.state == "changes_requested":
self.store.consume_receipt(draft.id)
cadence = str(
(self.config.get("notifications") or {}).get("decisions", "immediate")
)
if cadence not in {"immediate", "daily", "weekly", "off"}:
cadence = "immediate"
inserted += int(
self.store.persist_local_notice(
event_id=f"draft-decision:{draft.id}:{draft.state}",
kind="owner_decision",
skill_id=str(local["skill_id"]),
payload={
"draft_id": draft.id,
"slug": draft.slug,
"previous_state": previous,
"state": draft.state,
"moderation_note": draft.moderationNote,
"moderation_decider_user_id": draft.moderationDeciderUserId,
"moderation_decided_at": draft.moderationDecidedAt,
},
cadence=cadence,
)
)
return {"inserted": inserted}
def notifications(self, *, mark_seen: bool = False) -> dict[str, Any]:
events = self.store.feed_events(unseen_only=True)
notifications, ignored = self._notification_projection(events)
self.store.mark_feed_local_seen(ignored)
if mark_seen:
source_ids = [
str(event_id)
for item in notifications
for event_id in item["source_event_ids"]
]
self.store.mark_feed_local_seen(source_ids)
return {"events": notifications}
def dispatch_telegram(self) -> dict[str, Any]:
from .mediation import delivery_mode
if delivery_mode(self.config) == "agent":
return {"attempted": False, "delivered": 0}
now = datetime.now(timezone.utc).isoformat()
due = self.store.feed_events(telegram_due_at=now)
if not due:
return {"attempted": False, "delivered": 0}
notifications, ignored = self._notification_projection(due)
try:
from gateway.config import Platform, load_gateway_config
home = load_gateway_config().get_home_channel(Platform.TELEGRAM)
private_home = bool(home and not str(home.chat_id).startswith("-"))
except Exception:
private_home = False
excluded: list[str] = []
if not private_home:
public_notifications: list[dict[str, Any]] = []
for event in notifications:
if _public_notification_safe(event):
public_notifications.append(event)
else:
excluded.extend(
str(event_id) for event_id in event["source_event_ids"]
)
notifications = public_notifications
selected = notifications[:8]
if not selected:
self.store.mark_feed_telegram_delivered([*ignored, *excluded])
return {"attempted": False, "delivered": 0}
lines = [
"<b>Collective Wisdom</b>",
f"{len(selected)} new {'notification' if len(selected) == 1 else 'notifications'}",
]
button_rows: list[list[dict[str, str]]] = []
rich_items: list[dict[str, object]] = []
for event in selected:
heading, detail = self._telegram_notification_text(event)
checks = (
full_review_text(
event.get("security_check"), event.get("professionalism_check")
)
if private_home
else aggregate_review_text(
event.get("security_check"), event.get("professionalism_check")
)
)
detail = f"{detail}\n{checks}"
lines.extend(["", f"<b>{escape(heading)}</b>", escape(detail)])
rich_items.append({"heading": heading, "detail": detail})
row: list[dict[str, str]] = []
portal_url = event.get("portal_url")
if isinstance(portal_url, str) and portal_url:
row.append({
"label": "View",
"url": portal_url,
})
if private_home and event["category"] == "new_skill":
row.append({
"label": "Install",
"callback_data": f"wi:plan:install:{event['skill_id']}",
})
elif private_home and event["category"] == "update_available":
row.append({
"label": "Update",
"callback_data": f"wi:plan:update:{event['skill_id']}",
})
# Keep one row per notification even when an item has no actions,
# so rich-message controls cannot drift onto the next skill.
button_rows.append(row)
try:
from tools.wisdom_notifications import send_telegram_notification_pane
raw = send_telegram_notification_pane(
message="\n".join(lines),
button_rows=button_rows,
items=rich_items,
)
result = json.loads(raw) if isinstance(raw, str) else raw
except Exception as exc:
return {"attempted": True, "delivered": 0, "error": str(exc)}
if not isinstance(result, dict) or not result.get("success"):
return {
"attempted": True,
"delivered": 0,
"error": str(
result.get("error") if isinstance(result, dict) else result
),
}
ids = [
str(event_id) for item in selected for event_id in item["source_event_ids"]
]
ids.extend(ignored)
ids.extend(excluded)
self.store.mark_feed_telegram_delivered(ids)
return {"attempted": True, "delivered": len(selected)}
def dispatch_slack(self) -> dict[str, Any]:
from .mediation import delivery_mode
if delivery_mode(self.config) == "agent":
return {"attempted": False, "delivered": 0}
"""Deliver due Wisdom feed items to Slack's configured home chat.
A public home channel receives only collective publication notices and
Portal links. Device-local install/update state and mutation controls
are emitted only when the configured home is a DM.
"""
try:
from gateway.config import Platform, load_gateway_config
config = load_gateway_config()
platform_config = config.platforms.get(Platform.SLACK)
home = config.get_home_channel(Platform.SLACK)
if not platform_config or not platform_config.enabled or not home:
return {"attempted": False, "delivered": 0}
private_home = str(home.chat_id).startswith("D")
except Exception:
return {"attempted": False, "delivered": 0}
now = datetime.now(timezone.utc).isoformat()
due = self.store.feed_events(surface="slack", surface_due_at=now)
if not due:
return {"attempted": False, "delivered": 0}
notifications, ignored = self._notification_projection(due)
excluded: list[str] = []
if not private_home:
public_notifications: list[dict[str, Any]] = []
for event in notifications:
if _public_notification_safe(event):
public_notifications.append(event)
else:
excluded.extend(
str(event_id) for event_id in event["source_event_ids"]
)
notifications = public_notifications
selected = notifications[:8]
if not selected:
self.store.mark_feed_surface_delivered(
[*ignored, *excluded], surface="slack"
)
return {"attempted": False, "delivered": 0}
lines = [
"Collective Wisdom",
f"{len(selected)} new {'notification' if len(selected) == 1 else 'notifications'}",
]
button_rows: list[list[dict[str, str]]] = []
items: list[dict[str, object]] = []
for event in selected:
heading, detail = self._telegram_notification_text(event)
checks = (
full_review_text(
event.get("security_check"), event.get("professionalism_check")
)
if private_home
else aggregate_review_text(
event.get("security_check"), event.get("professionalism_check")
)
)
detail = f"{detail}\n{checks}"
lines.extend(["", heading, detail])
items.append({"heading": heading, "detail": detail})
row: list[dict[str, str]] = []
portal_url = event.get("portal_url")
if isinstance(portal_url, str) and portal_url:
row.append({"label": "View in Portal", "url": portal_url})
if private_home and event["category"] == "new_skill":
row.append({
"label": "Install",
"callback_data": f"wi:plan:install:{event['skill_id']}",
})
elif private_home and event["category"] == "update_available":
row.append({
"label": "Update",
"callback_data": f"wi:plan:update:{event['skill_id']}",
})
button_rows.append(row)
try:
from tools.wisdom_notifications import send_slack_wisdom_notification_pane
result = send_slack_wisdom_notification_pane(
message="\n".join(lines),
button_rows=button_rows,
items=items,
)
except Exception as exc:
return {"attempted": True, "delivered": 0, "error": str(exc)}
if not isinstance(result, dict) or not result.get("success"):
return {
"attempted": True,
"delivered": 0,
"error": str(
result.get("error") if isinstance(result, dict) else result
),
}
delivered_ids = [
str(event_id) for item in selected for event_id in item["source_event_ids"]
]
self.store.mark_feed_surface_delivered(
[*delivered_ids, *ignored, *excluded], surface="slack"
)
return {"attempted": True, "delivered": len(selected)}