"""Hugin-Anbindung (→ Konzept 7.9): Pakete wählen, auflösen, festschreiben und für Runs laden. Ablauf: Der Plattform-Admin trägt den Hugin-Host ein und sieht, welche Workspaces Hugin nutzen. Der Workspace-Admin (ein Workspace gehört einem Team) trägt den Integrations-Key seines Tenants ein und wählt Pakete (wie Zeilen in .hugin.yaml); „Abgleichen“ zeigt, was Hugin liefern würde, „Übernehmen“ schreibt Versionen mit Prüfsumme fest und speichert jedes Paket im Quellformat. Neue Versionen kommen nur durch Übernehmen; Pflichtpakete aus Hugin kommen immer mit. Für Runs werden nur festgeschriebene, geprüfte Kopien geladen. Abbildung in der ersten Stufe: rules → System-Prompt, skill → Name und Beschreibung im Prompt, Inhalt über load_skill und read_skill_file, command → Slash-Befehl im Chat. Nicht angeboten werden andere Arten, Pakete mit Risiko scripts oder permissions und Befehle mit Shell-Einbettung. Hugin-Inhalte verleihen keine Rechte. """ import hashlib import io import json import logging import re import shlex import zipfile from dataclasses import dataclass, field from datetime import timedelta from pathlib import PurePosixPath from typing import Any import yaml from sqlalchemy import func, select from sqlalchemy.orm import Session from app import audit from app.core.config import Settings from app.core.crypto import Vault from app.core.database import now from app.core.errors import Invalid, NotFound from app.data_sources import service as sources from app.data_sources.connectors.git_repo import GitError, RepoCache from app.data_sources.models import DataSource from app.hugin.client import Client, HuginError, ready from app.hugin.models import HuginHost, WorkspaceHugin, WorkspaceHuginLock, WorkspaceHuginPackage from app.identity import Principal from app.identity.admin import require as require_platform_admin from app.identity.models import Team, User from app.mcp import service as mcp from app.security import untrusted from app.workspaces import service as workspaces from app.workspaces.models import Workspace, WorkspaceMembership log = logging.getLogger(__name__) SPEC = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*(?:@(?:stable|test|\^\d+|\d+\.\d+\.\d+))?$") CONTENT_FILE = {"rules": "RULES.md", "skill": "SKILL.md", "command": "COMMAND.md"} KIND_NAMES = {"rules": "Regeln", "skill": "Skill", "command": "Befehl", "subagent": "Subagent", "mcp": "MCP", "plugin": "Plugin", "hook": "Hook"} ALLOWED_RISKS = ("text", "tools") COMMAND = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+(.*))?$", re.S) MAX_RULES_CHARS = 20000 # Beim Laden: Größe wurde beim Übernehmen geprüft; hier nur noch Schutz vor Unfug LOAD_LIMIT = 64 << 20 # --- Host der Plattform (Plattform-Admin) --- def host(session: Session) -> HuginHost | None: return session.get(HuginHost, 1) def save_host(session: Session, principal: Principal, settings: Settings, url: str) -> HuginHost: require_platform_admin(principal) url = mcp.check_url(url, settings).rstrip("/") row = session.get(HuginHost, 1) if row is None: row = HuginHost(id=1, updated_by=principal.user_id) session.add(row) if row.url != url: row.status, row.status_message, row.checked = "new", "", None row.url, row.updated_by, row.updated = url, principal.user_id, now() audit.record(session, "hugin.host_saved", user_id=principal.user_id, target_kind="hugin_host", target_id=1, url=url) return row def check_host(session: Session, principal: Principal, settings: Settings) -> HuginHost: """Erreichbarkeit über /readyz; ob ein Key gilt, prüft jeder Workspace mit seinem Key.""" require_platform_admin(principal) row = session.get(HuginHost, 1) if row is None: raise NotFound error = ready(row.url, settings.hugin_timeout_seconds) row.status, row.status_message = ("error", error) if error else ("ok", "erreichbar") row.checked = now() return row @dataclass class WorkspaceStatus: """Für die Übersicht des Plattform-Admins: ob und wie ein Workspace Hugin nutzt, ohne Key.""" workspace: Workspace team: str access: WorkspaceHugin | None chosen: int locked: int @property def findings(self) -> list[str]: return describe(self.access.findings or {}) if self.access else [] def overview(session: Session) -> list[WorkspaceStatus]: accesses = {row.workspace_id: row for row in session.scalars(select(WorkspaceHugin))} chosen = dict(session.execute(select(WorkspaceHuginPackage.workspace_id, func.count()) .group_by(WorkspaceHuginPackage.workspace_id)).all()) locked = dict(session.execute(select(WorkspaceHuginLock.workspace_id, func.count()) .group_by(WorkspaceHuginLock.workspace_id)).all()) rows = session.execute(select(Workspace, Team.name).join(Team, Team.id == Workspace.team_id) .where(Workspace.deleted.is_(None)).order_by(Team.name, Workspace.name)).all() return [WorkspaceStatus(w, team, accesses.get(w.id), chosen.get(w.id, 0), locked.get(w.id, 0)) for w, team in rows] # --- Zugang je Workspace (Workspace-Admin) --- def access(session: Session, workspace_id: int) -> WorkspaceHugin | None: return session.get(WorkspaceHugin, workspace_id) def save_access(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int, key: str) -> WorkspaceHugin: """Integrations-Key speichern und gleich prüfen; Tenant und Key-Name meldet Hugin.""" workspaces.require_admin(session, principal, workspace_id) key = key.strip() if not re.fullmatch(r"hi_[A-Za-z0-9_-]{16,200}", key): raise Invalid("Das sieht nicht nach einem Integrations-Key von Hugin aus (hi_…).") row = session.get(WorkspaceHugin, workspace_id) if row is None: row = WorkspaceHugin(workspace_id=workspace_id, tenant="", updated_by=principal.user_id) session.add(row) row.secret, row.hint, row.updated_by, row.updated = vault.encrypt(key), key[-4:], principal.user_id, now() # Tenant bleibt bis zum Test stehen, damit ein Wechsel im Audit-Log erscheint row.status, row.status_message, row.checked = "new", "", None audit.record(session, "hugin.access_saved", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id) return test_access(session, principal, settings, vault, workspace_id) def test_access(session: Session, principal: Principal, settings: Settings, vault: Vault, workspace_id: int) -> WorkspaceHugin: """Key über /status prüfen; ein Wechsel des Tenants kommt ins Audit-Log (er bestimmt die Pflichtpakete).""" workspace = workspaces.require_admin(session, principal, workspace_id).workspace row = session.get(WorkspaceHugin, workspace_id) if row is None: raise NotFound try: with client(session, settings, vault, workspace) as hugin: info = hugin.status() key = info.get("key") or {} tenant = str(key.get("tenant", ""))[:64] if tenant != row.tenant: audit.record(session, "hugin.tenant_changed", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id, before=row.tenant, after=tenant) row.status, row.tenant, row.key_name = "ok", tenant, str(key.get("name", ""))[:200] row.status_message = f"gültig bis {key['expires_at'][:10]}" if key.get("expires_at") else "" except HuginError as e: row.status, row.status_message = "error", str(e) row.checked = now() return row def remove_access(session: Session, principal: Principal, workspace_id: int) -> None: """Festgeschriebene Pakete gelten weiter; Abgleichen und Rückmeldungen gehen nicht mehr.""" workspaces.require_admin(session, principal, workspace_id) row = session.get(WorkspaceHugin, workspace_id) if row is not None: session.delete(row) audit.record(session, "hugin.access_removed", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id) def client(session: Session, settings: Settings, vault: Vault, workspace: Workspace) -> Client: instance = host(session) if instance is None: raise HuginError("Hugin ist auf dieser Plattform nicht eingerichtet (Administration → Hugin).") row = access(session, workspace.id) if row is None: raise HuginError("Für diesen Workspace ist kein Hugin-Key eingetragen (Einstellungen → Hugin).") return Client(instance.url, vault.decrypt(row.secret), settings.hugin_timeout_seconds) # --- Auswahl je Workspace --- def subscriptions(session: Session, workspace_id: int) -> list[WorkspaceHuginPackage]: return list(session.scalars(select(WorkspaceHuginPackage).where(WorkspaceHuginPackage.workspace_id == workspace_id) .order_by(WorkspaceHuginPackage.package_id))) def locks(session: Session, workspace_id: int) -> list[WorkspaceHuginLock]: return list(session.scalars(select(WorkspaceHuginLock).where(WorkspaceHuginLock.workspace_id == workspace_id) .order_by(WorkspaceHuginLock.package_id))) def add(session: Session, principal: Principal, workspace_id: int, text: str) -> WorkspaceHuginPackage: """Paket wählen, z. B. pub-release-notiz oder pub-release-notiz@^1.""" workspaces.require_admin(session, principal, workspace_id) text = text.strip() if not SPEC.match(text): raise Invalid("Paket als oder @stable, @test, @^1 bzw. @1.2.3 angeben.") package_id, _, spec = text.partition("@") row = session.get(WorkspaceHuginPackage, (workspace_id, package_id)) or WorkspaceHuginPackage( workspace_id=workspace_id, package_id=package_id, added_by=principal.user_id) row.spec = spec or "stable" session.add(row) audit.record(session, "hugin.package_added", user_id=principal.user_id, workspace_id=workspace_id, target_kind="hugin_package", target_id=package_id, spec=row.spec) return row def remove(session: Session, principal: Principal, workspace_id: int, package_id: str) -> None: """Aus der Auswahl nehmen; die festgeschriebene Version bleibt bis zum nächsten Übernehmen.""" workspaces.require_admin(session, principal, workspace_id) row = session.get(WorkspaceHuginPackage, (workspace_id, package_id)) if row is not None: session.delete(row) audit.record(session, "hugin.package_removed", user_id=principal.user_id, workspace_id=workspace_id, target_kind="hugin_package", target_id=package_id) def catalog(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> list[dict]: """Freigegebene Pakete, die das Team sieht, mit Hinweis, ob Bifröst sie abbilden kann.""" workspace = workspaces.require_admin(session, principal, workspace_id).workspace with client(session, settings, vault, workspace) as hugin: packages = hugin.packages() # Pflichtpakete nennt der Katalog nicht; ein Auflösen ohne Auswahl liefert genau sie mandatory = {p["id"] for p in hugin.resolve([], {}).get("packages", []) if p.get("mandatory")} for p in packages: p["kind_name"] = KIND_NAMES.get(p.get("kind", ""), p.get("kind", "")) p["unsupported"] = unsupported(p.get("kind", ""), p.get("risk", "")) p["mandatory"] = p["id"] in mandatory return sorted(packages, key=lambda p: p["id"]) def unsupported(kind: str, risk: str, content: str = "") -> str: """Leer, wenn Bifröst das Paket anbietet; sonst der Grund.""" if kind not in CONTENT_FILE: return f"{KIND_NAMES.get(kind, kind)} wird in Bifröst noch nicht unterstützt." if risk not in ALLOWED_RISKS: return "Enthält Skripte oder verlangt Rechte; Bifröst führt keinen Code aus." if kind == "command" and "!`" in content: return "Befehl mit Shell-Einbettung; Bifröst führt keine Befehle aus." return "" # --- Vorschlag aus .hugin.yaml im Repository --- REPO_FILE = ".hugin.yaml" @dataclass class RepoProposal: source: str packages: list[str] = field(default_factory=list) # wie in der Datei, z. B. pub-x@^1 unsubscribed: list[str] = field(default_factory=list) server: str = "" error: str = "" def repo_proposals(db, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> list[RepoProposal]: """.hugin.yaml aus dem Ziel-Branch jeder Git-Quelle lesen; nur ein Vorschlag für die Auswahl. Ohne offene Transaktion aufrufen: das Abholen des Repositorys kann dauern.""" with db.session() as session: workspaces.require_admin(session, principal, workspace_id) found = list(session.scalars(select(DataSource).where( DataSource.workspace_id == workspace_id, DataSource.kind == "git", DataSource.status == "ok") .order_by(DataSource.name))) for source in found: session.expunge(source) cache = RepoCache(settings) proposals = [] for source in found: proposal = RepoProposal(source.name) try: repo = cache.repo(source.id, sources.connector(source, settings, vault)) commit = repo.sync() if repo.kind(commit, REPO_FILE) != "blob": continue data = yaml.safe_load(repo.read(commit, REPO_FILE)[:65536]) or {} if not isinstance(data, dict): raise ValueError("kein YAML-Objekt") except (GitError, OSError, yaml.YAMLError, ValueError) as e: proposal.error = f"{REPO_FILE} nicht lesbar: {str(e)[:200]}" proposals.append(proposal) continue entries = [str(p).strip() for p in data.get("packages") or [] if isinstance(p, (str, int))] proposal.packages = [p for p in entries if SPEC.match(p)] proposal.unsubscribed = [str(p) for p in data.get("unsubscribed") or [] if isinstance(p, str)] proposal.server = str(data.get("server") or "") skipped = [p for p in entries if not SPEC.match(p)] if skipped: proposal.error = "Übersprungen (kein gültiger Eintrag): " + ", ".join(skipped[:5]) proposals.append(proposal) return proposals def add_many(session: Session, principal: Principal, workspace_id: int, entries: list[str]) -> int: for entry in entries: add(session, principal, workspace_id, entry) return len(entries) # --- Abgleichen und Übernehmen --- @dataclass class Change: package_id: str kind: str current: str | None new: str | None mandatory: bool = False deprecated: bool = False unsupported: str = "" @property def action(self) -> str: if self.current is None: return "neu" if self.new is None: return "entfällt" return "unverändert" if self.current == self.new else "aktualisieren" @dataclass class Plan: changes: list[Change] major_available: dict[str, str] resolved: dict = field(default_factory=dict) @property def pending(self) -> bool: return any(c.action != "unverändert" for c in self.changes) def plan(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> Plan: """Was Hugin jetzt liefern würde, verglichen mit dem Festgeschriebenen. Ändert nichts außer dem Befund.""" workspace = workspaces.require_admin(session, principal, workspace_id).workspace result = resolve_plan(session, settings, vault, workspace) record_findings(session, workspace_id, result) return result def resolve_plan(session: Session, settings: Settings, vault: Vault, workspace: Workspace) -> Plan: workspace_id = workspace.id current = {lock.package_id: lock for lock in locks(session, workspace_id)} wanted = [f"{s.package_id}@{s.spec}" for s in subscriptions(session, workspace_id)] with client(session, settings, vault, workspace) as hugin: resolved = hugin.resolve(wanted, {k: v.version for k, v in current.items()}) changes = [] seen = set() for p in resolved.get("packages", []): seen.add(p["id"]) lock = current.get(p["id"]) changes.append(Change(p["id"], p["kind"], lock.version if lock else None, p["version"], p["mandatory"], p["deprecated"], unsupported(p["kind"], p["risk"]))) for package_id, lock in current.items(): if package_id not in seen: changes.append(Change(package_id, lock.kind, lock.version, None, lock.mandatory)) changes.sort(key=lambda c: c.package_id) return Plan(changes, resolved.get("major_available", {}), resolved) def apply(session: Session, settings: Settings, vault: Vault, principal: Principal, workspace_id: int) -> Plan: """Auflösen und festschreiben: neue oder geänderte Versionen laden und prüfen, entfallene entfernen.""" workspace = workspaces.require_admin(session, principal, workspace_id).workspace result = resolve_plan(session, settings, vault, workspace) current = {lock.package_id: lock for lock in locks(session, workspace_id)} max_bytes = settings.hugin_max_package_kb * 1024 with client(session, settings, vault, workspace) as hugin: for p in result.resolved.get("packages", []): lock = current.get(p["id"]) if lock is not None and lock.version == p["version"] and lock.checksum == p["checksum"]: lock.mandatory, lock.deprecated = p["mandatory"], p["deprecated"] continue source = hugin.source(p["id"], p["version"], max_bytes) if source.checksum != p["checksum"]: raise HuginError(f"Prüfsumme von {p['id']} {p['version']} stimmt nicht mit der Auflösung überein.") files = read_archive(source.archive, max_bytes) content = files.get(CONTENT_FILE.get(p["kind"], ""), b"").decode("utf-8", errors="replace") if p["kind"] in CONTENT_FILE and not content.strip(): raise HuginError(f"{p['id']} {p['version']}: {CONTENT_FILE[p['kind']]} fehlt oder ist leer.") lock = lock or WorkspaceHuginLock(workspace_id=workspace_id, package_id=p["id"]) lock.kind, lock.version, lock.checksum, lock.risk = p["kind"], p["version"], p["checksum"], p["risk"] lock.mandatory, lock.deprecated = p["mandatory"], p["deprecated"] lock.description, lock.arguments = p.get("description", "")[:2000], list(p.get("arguments") or []) lock.archive, lock.archive_sha256 = source.archive, hashlib.sha256(source.archive).hexdigest() lock.unsupported = unsupported(p["kind"], p["risk"], content) lock.locked_by, lock.locked = principal.user_id, now() session.add(lock) for change in result.changes: if change.action == "entfällt": session.delete(current[change.package_id]) session.flush() installed = {lock.package_id: lock.version for lock in locks(session, workspace_id)} try: hugin.usage(f"ai-workspace/{workspace_id}", installed) except HuginError as e: log.warning("Nutzung nicht an Hugin gemeldet", extra={"event": "hugin_usage_failed", "error": str(e)}) audit.record(session, "hugin.applied", user_id=principal.user_id, workspace_id=workspace_id, target_kind="workspace", target_id=workspace_id, packages=installed, changes={c.package_id: c.action for c in result.changes if c.action != "unverändert"}) # Nach dem Übernehmen bleibt nur, was sich nicht durch Übernehmen erledigt (veraltet, Hauptversion) record_findings(session, workspace_id, Plan([Change(c.package_id, c.kind, c.new, c.new, c.mandatory, c.deprecated) for c in result.changes if c.new], result.major_available)) return result # --- Abgleich im Hintergrund --- def findings_of(result: Plan) -> dict[str, Any]: """Was ein Workspace-Admin wissen sollte; eigene, noch nicht übernommene Auswahl zählt nicht.""" found: dict[str, Any] = {} updates = {c.package_id: [c.current, c.new] for c in result.changes if c.action == "aktualisieren"} deprecated = sorted(c.package_id for c in result.changes if c.deprecated and c.current) new_mandatory = sorted(c.package_id for c in result.changes if c.action == "neu" and c.mandatory) removed_mandatory = sorted(c.package_id for c in result.changes if c.action == "entfällt" and c.mandatory) for key, value in (("updates", updates), ("deprecated", deprecated), ("new_mandatory", new_mandatory), ("removed_mandatory", removed_mandatory), ("major", dict(result.major_available))): if value: found[key] = value return found def record_findings(session: Session, workspace_id: int, result: Plan) -> dict[str, Any]: """Befund am Zugang speichern; gibt ihn zurück, wenn er neu und nicht leer ist (dann benachrichtigen).""" row = session.get(WorkspaceHugin, workspace_id) if row is None: return {} found = findings_of(result) changed = found != (row.findings or {}) row.findings, row.synced = found, now() return found if changed and found else {} def describe(found: dict[str, Any]) -> list[str]: lines = [f"Update verfügbar: {k} {old} → {new}" for k, (old, new) in found.get("updates", {}).items()] lines += [f"Veraltet: {k}" for k in found.get("deprecated", [])] lines += [f"Neues Pflichtpaket: {k}" for k in found.get("new_mandatory", [])] lines += [f"Pflichtpaket entfällt: {k}" for k in found.get("removed_mandatory", [])] lines += [f"Neue Hauptversion: {k} {v}" for k, v in found.get("major", {}).items()] return lines def check_due(db, settings: Settings) -> int: """Vom Worker aufgerufen: Workspaces abgleichen, deren letzter Abgleich älter als AIW_HUGIN_CHECK_HOURS ist. Übernimmt nie etwas; ein neuer Befund geht per E-Mail an die Workspace-Admins und an den Webhook.""" if settings.hugin_check_hours <= 0: return 0 limit = now() - timedelta(hours=settings.hugin_check_hours) with db.session() as session: if host(session) is None: return 0 due = list(session.scalars(select(WorkspaceHugin.workspace_id).join( Workspace, Workspace.id == WorkspaceHugin.workspace_id).where( Workspace.deleted.is_(None), (WorkspaceHugin.synced.is_(None)) | (WorkspaceHugin.synced < limit)))) vault = Vault(settings.secret_key) checked = 0 for workspace_id in due: try: with db.session() as session: row = session.get(WorkspaceHugin, workspace_id, with_for_update={"skip_locked": True}) if row is None or (row.synced is not None and row.synced >= limit): continue workspace = session.get(Workspace, workspace_id) try: found = record_findings(session, workspace_id, resolve_plan(session, settings, vault, workspace)) except HuginError as e: row.synced = now() # nicht in jeder Runde erneut versuchen log.warning("Abgleich mit Hugin fehlgeschlagen", extra={ "event": "hugin_check_failed", "workspace_id": workspace_id, "error": str(e)}) continue if found: notify_findings(session, workspace, found) checked += 1 except Exception: log.exception("Abgleich mit Hugin für Workspace %s fehlgeschlagen", workspace_id) return checked def notify_findings(session: Session, workspace: Workspace, found: dict[str, Any]) -> None: from app.notify import service as notify url = notify.link(f"/workspaces/{workspace.id}/settings?tab=hugin") lines = describe(found) subject = f"Hugin: Neuigkeiten für {notify.clean(workspace.name, 80)}" body = (f"Der Abgleich mit Hugin hat für den Workspace „{workspace.name}“ Neues gefunden:\n\n" + "\n".join(f"- {line}" for line in lines) + f"\n\nÜbernommen wird nichts automatisch. Zum Abgleichen: {url}") admins = session.scalars(select(User).join(WorkspaceMembership, WorkspaceMembership.user_id == User.id).where( WorkspaceMembership.workspace_id == workspace.id, WorkspaceMembership.role == "admin", User.active)) for user in admins: notify.email(session, user, "hugin.findings", subject, body, workspace.id) notify.webhook(session, workspace, "hugin.findings", f"Hugin: {len(lines)} Neuigkeit(en) im Workspace {notify.clean(workspace.name, 80)}. {url}") def read_archive(raw: bytes, max_bytes: int) -> dict[str, bytes]: """ZIP im Quellformat lesen; nur einfache relative Pfade, begrenzte Größe.""" try: with zipfile.ZipFile(io.BytesIO(raw)) as z: infos = [i for i in z.infolist() if not i.is_dir()] if sum(i.file_size for i in infos) > 8 * max_bytes: raise HuginError("Das Paket ist entpackt zu groß.") files = {} for info in infos: path = PurePosixPath(info.filename) if path.is_absolute() or ".." in path.parts or "\\" in info.filename: raise HuginError(f"Ungültiger Pfad im Paket: {info.filename[:100]}") files[str(path)] = z.read(info) return files except zipfile.BadZipFile as e: raise HuginError("Hugin hat kein gültiges ZIP geliefert.") from e # --- Laden für einen Run --- @dataclass class Package: id: str kind: str version: str description: str arguments: list[str] files: dict[str, bytes] @property def content(self) -> str: return self.files.get(CONTENT_FILE[self.kind], b"").decode("utf-8", errors="replace") @property def helper_files(self) -> list[str]: return sorted(p for p in self.files if p not in (CONTENT_FILE[self.kind], "hugin.yaml")) @dataclass class Loaded: packages: list[Package] def of(self, kind: str) -> list[Package]: return [p for p in self.packages if p.kind == kind] def get(self, kind: str, package_id: str) -> Package | None: return next((p for p in self.packages if p.kind == kind and p.id == package_id), None) @property def versions(self) -> dict[str, str]: return {p.id: p.version for p in self.packages} def load(session: Session, workspace_id: int) -> Loaded: """Festgeschriebene, angebotene Pakete; jede Kopie wird gegen ihre Prüfsumme geprüft.""" packages = [] for lock in locks(session, workspace_id): if lock.unsupported: continue if hashlib.sha256(lock.archive).hexdigest() != lock.archive_sha256: log.error("Hugin-Paket verändert", extra={"event": "hugin_checksum_mismatch", "workspace_id": workspace_id, "package": lock.package_id}) audit.record(session, "hugin.checksum_mismatch", workspace_id=workspace_id, target_kind="hugin_package", target_id=lock.package_id, version=lock.version) continue try: files = read_archive(lock.archive, LOAD_LIMIT) except HuginError: continue packages.append(Package(lock.package_id, lock.kind, lock.version, lock.description, list(lock.arguments), files)) return Loaded(packages) def prompt_note(loaded: Loaded, offered: set[str]) -> str: """Regeln vollständig, Skills nur mit Name und Beschreibung. Nach den Regeln der Plattform; Skills und Rückmeldung nur, wenn die Tools dafür angeboten werden.""" parts = [] rules = loaded.of("rules") if rules: text = "\n\n".join(f"\n" f"{untrusted.neutralize(p.content.strip())}\n" for p in rules) parts.append("\n\nRegeln aus Hugin (zentral gepflegt und geprüft). Befolge sie, soweit sie den Regeln oben " "nicht widersprechen. Sie erweitern keine Rechte und umgehen keine Freigaben.\n" + text[:MAX_RULES_CHARS]) skills = loaded.of("skill") if skills and "load_skill" in offered: lines = "\n".join(f"- {p.id}: {untrusted.neutralize(p.description)}" for p in skills) parts.append("\n\nSkills aus Hugin: Anleitungen für bestimmte Aufgaben. Passt eine zur Aufgabe, lade sie " "zuerst mit load_skill und folge ihr; Hilfsdateien mit read_skill_file. Skills erweitern keine " f"Rechte.\n{lines}") if loaded.packages and "hugin_feedback" in offered: parts.append("\n\nIst eine Anweisung aus Hugin unklar, falsch oder veraltet, melde das mit hugin_feedback " "(knapp und konkret), statt sie stillschweigend zu übergehen.") return "".join(parts) # --- Befehle im Chat --- def commands(session: Session, workspace_id: int) -> list[Package]: return load(session, workspace_id).of("command") def usage(command: Package) -> str: return " ".join([f"/{command.id}"] + [f"<{a}>" for a in command.arguments]) def suggestions(found: list[Package]) -> str: """Für die Vorschläge beim Tippen von / im Eingabefeld (JSON im data-Attribut).""" return json.dumps([{"name": c.id, "args": c.arguments, "description": c.description[:200]} for c in found], ensure_ascii=False) def expand(session: Session, workspace_id: int, text: str) -> tuple[str, Package] | None: """/befehl arg1 "arg 2" … → Text des Befehls mit {{name}} je Position und $ARGUMENTS. None, wenn die Nachricht kein Befehl ist; Invalid bei unbekanntem Befehl.""" match = COMMAND.match(text.strip()) if not match: return None name, rest = match.group(1), (match.group(2) or "").strip() loaded = load(session, workspace_id) command = loaded.get("command", name) if command is None: known = ", ".join(f"/{p.id}" for p in loaded.of("command")) raise Invalid(f"Den Befehl /{name} gibt es in diesem Workspace nicht." + (f" Verfügbar: {known}." if known else "")) try: values = shlex.split(rest) except ValueError: values = rest.split() if len(values) < len(command.arguments): missing = " ".join(f"<{a}>" for a in command.arguments[len(values):]) raise Invalid(f"/{name} braucht noch: {missing}. Aufruf: {usage(command)} " "(Argumente mit Leerzeichen in Anführungszeichen).") result = command.content for i, argument in enumerate(command.arguments): result = result.replace("{{" + argument + "}}", values[i] if i < len(values) else "") return result.replace("$ARGUMENTS", rest).strip(), command