Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.6 KiB
Python
140 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import itertools
|
|
import json
|
|
import math
|
|
import re
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from .council import HUMAN, agent
|
|
from .events import event
|
|
from .records import read_json, write_atomic
|
|
from .transcripts import STREAM_AGE
|
|
|
|
PSEUDO = "name:"
|
|
TITLE_SCAN = 10.0
|
|
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
|
|
|
|
class Directory:
|
|
def __init__(self, entries: list[dict]) -> None:
|
|
self.by_session: dict[str, list[dict]] = {}
|
|
self.by_name: dict[str, list[str]] = {}
|
|
for entry in entries:
|
|
self.by_session.setdefault(entry["session"], []).append(entry)
|
|
holders = self.by_name.setdefault(entry["name"], [])
|
|
if entry["session"] in holders:
|
|
holders.remove(entry["session"])
|
|
holders.append(entry["session"])
|
|
|
|
def at(self, session: str, ts: float) -> str:
|
|
own = self.by_session.get(session)
|
|
if not own:
|
|
return ""
|
|
valid = [entry for entry in own if entry["first_seen"] <= ts]
|
|
return (valid[-1] if valid else own[0])["name"]
|
|
|
|
def now(self, session: str) -> str:
|
|
own = self.by_session.get(session)
|
|
return own[-1]["name"] if own else ""
|
|
|
|
def find(self, name: str, ts: float) -> str:
|
|
holders = self.by_name.get(name, [])
|
|
real = [session for session in holders if not session.startswith(PSEUDO)]
|
|
valid = [session for session in real if self.at(session, ts) == name]
|
|
chosen = valid or real or holders
|
|
return chosen[-1] if chosen else ""
|
|
|
|
def key(self, name: str, ts: float) -> str:
|
|
if not name or name == HUMAN:
|
|
return name
|
|
return self.find(name, ts) or f"{PSEUDO}{name}"
|
|
|
|
def label(self, key: str, ts: float) -> str:
|
|
return self.at(key, ts) or key.removeprefix(PSEUDO)
|
|
|
|
def names(self, key: str) -> list[str]:
|
|
return list(dict.fromkeys(entry["name"] for entry in self.by_session.get(key, [])))
|
|
|
|
def timeline(self, key: str) -> list[dict]:
|
|
return [{"name": entry["name"], "since": entry["first_seen"]} for entry in self.by_session.get(key, [])]
|
|
|
|
def aliases(self, name: str) -> set[str]:
|
|
return set(self.by_name.get(name, []))
|
|
|
|
def renames(self, begin: float, end: float) -> list[dict]:
|
|
found = []
|
|
for session, own in self.by_session.items():
|
|
for before, after in itertools.pairwise(own):
|
|
if before["name"] != after["name"] and begin <= after["first_seen"] <= end:
|
|
found.append(event({
|
|
"id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"],
|
|
"now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}",
|
|
"text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]},
|
|
}))
|
|
return found
|
|
|
|
|
|
class Names:
|
|
def __init__(self, file: Path) -> None:
|
|
self.file = file
|
|
self.lock = threading.Lock()
|
|
self.titled = -math.inf
|
|
data = read_json(file)
|
|
data = data if isinstance(data, dict) else {}
|
|
entries, observed = data.get("entries"), data.get("observed")
|
|
keys = {"session", "name", "first_seen", "source"}
|
|
self.entries = [entry for entry in entries if isinstance(entry, dict) and set(entry) == keys] if isinstance(entries, list) else []
|
|
self.observed: dict[str, str] = observed if isinstance(observed, dict) else {}
|
|
|
|
def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None:
|
|
with self.lock:
|
|
changed = False
|
|
for session, name, seen, source in sightings:
|
|
key = f"{session}\x00{source}"
|
|
if not session or not name or self.observed.get(key) == name:
|
|
continue
|
|
self.observed[key] = name
|
|
changed = True
|
|
history = [entry for entry in self.entries if entry["session"] == session]
|
|
if history and history[-1]["name"] == name:
|
|
continue
|
|
floor = history[-1]["first_seen"] if history else -math.inf
|
|
first = seen if seen is not None and seen > floor else max(now, floor)
|
|
self.entries.append({"session": session, "name": name, "first_seen": first, "source": source})
|
|
if changed:
|
|
write_atomic(self.file, json.dumps({"entries": self.entries, "observed": self.observed}, sort_keys=True).encode("utf-8"))
|
|
|
|
def listed(self) -> list[dict]:
|
|
with self.lock:
|
|
return [dict(entry) for entry in self.entries]
|
|
|
|
def due(self, now: float) -> bool:
|
|
with self.lock:
|
|
if now - self.titled < TITLE_SCAN:
|
|
return False
|
|
self.titled = now
|
|
return True
|
|
|
|
|
|
def title_sightings(root: Path, claimed: set[str], now: float) -> list[tuple[str, str, float | None, str]]:
|
|
try:
|
|
paths = sorted(root.glob("*/*/custom-title.json"))
|
|
except OSError:
|
|
return []
|
|
found = []
|
|
for path in paths:
|
|
session = path.parent.name
|
|
try:
|
|
changed = path.stat().st_mtime
|
|
except OSError:
|
|
continue
|
|
if not SESSION_ID.fullmatch(session) or (session not in claimed and changed < now - STREAM_AGE):
|
|
continue
|
|
data = read_json(path)
|
|
name = agent(data.get("customTitle") if isinstance(data, dict) else None)
|
|
if name:
|
|
found.append((session, name, changed, "title"))
|
|
return found
|